diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index 87a8b34..e7d0a83 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -5,6 +5,7 @@ on: branches: - master - cicd + - dev jobs: build: @@ -12,31 +13,54 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 - + + # Build backend - uses: actions/setup-go@v6 with: go-version: '^1.25' check-latest: false cache-dependency-path: | - modules/server/go.sum + modules/backend/go.sum + + - name: Build Go app + run: | + cd modules/backend + go mod tidy + go build -o nyanimedb . + tar -czvf nyanimedb-backend.tar.gz nyanimedb + - name: Upload built backend to artifactory + uses: actions/upload-artifact@v3 + with: + name: nyanimedb-backend.tar.gz + path: modules/backend/nyanimedb-backend.tar.gz + + # Build frontend + - uses: actions/setup-node@v5 + with: + node-version-file: modules/frontend/package.json + cache: npm + cache-dependency-path: modules/frontend/package-lock.json + + - name: Build frontend + env: + VITE_BACKEND_API_BASE_URL: ${{ vars.BACKEND_API_BASE_URL }} + run: | + cd modules/frontend + npm install + npm run build + tar -czvf nyanimedb-frontend.tar.gz dist/ + + - name: Upload built frontend to artifactory + uses: actions/upload-artifact@v3 + with: + name: nyanimedb-frontend.tar.gz + path: modules/frontend/nyanimedb-frontend.tar.gz + + # Build Docker images - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - # Build application - - name: Build Go app - run: | - cd modules/server - go mod tidy - go build -o nyanimedb . - - - name: Upload built application to artifactory - uses: actions/upload-artifact@v3 - with: - name: nyanimedb - path: modules/server/nyanimedb - - # Build Docker image - name: Login to Docker Hub uses: docker/login-action@v3 with: @@ -44,13 +68,21 @@ jobs: username: ${{ secrets.REGISTRY_USERNAME }} password: ${{ secrets.REGISTRY_TOKEN }} - - name: Build and push + - name: Build and push backend image uses: docker/build-push-action@v6 with: - context: ./modules/server - file: Dockerfiles/Dockerfile_server + context: . + file: Dockerfiles/Dockerfile_backend push: true - tags: meowgit.nekoea.red/nihonium/nyanimedb:latest + tags: meowgit.nekoea.red/nihonium/nyanimedb-backend:latest + + - name: Build and push frontend image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfiles/Dockerfile_frontend + push: true + tags: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest deploy: runs-on: self-hosted @@ -62,6 +94,7 @@ jobs: POSTGRES_PORT: 5432 POSTGRES_VERSION: 18 LOG_LEVEL: ${{ vars.LOG_LEVEL }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} steps: - name: Checkout code @@ -71,4 +104,5 @@ jobs: run: | cd deploy docker compose down || true + docker compose pull || true docker compose up -d diff --git a/Dockerfiles/Dockerfile_backend b/Dockerfiles/Dockerfile_backend new file mode 100644 index 0000000..68c2414 --- /dev/null +++ b/Dockerfiles/Dockerfile_backend @@ -0,0 +1,6 @@ +FROM ubuntu:22.04 + +WORKDIR /app +COPY --chmod=755 modules/backend/nyanimedb /app +EXPOSE 8080 +ENTRYPOINT ["/app/nyanimedb"] \ No newline at end of file diff --git a/Dockerfiles/Dockerfile_frontend b/Dockerfiles/Dockerfile_frontend index 9f0752e..18bc6d7 100644 --- a/Dockerfiles/Dockerfile_frontend +++ b/Dockerfiles/Dockerfile_frontend @@ -1,13 +1,5 @@ -FROM node:20-alpine AS builder -ARG VITE_BACKEND_API_BASE_URL -ENV VITE_BACKEND_API_BASE_URL=$VITE_BACKEND_API_BASE_URL -WORKDIR /app -COPY modules/frontend/ ./ -RUN echo "VITE_BACKEND_API_BASE_URL=$VITE_BACKEND_API_BASE_URL" -RUN npm install -RUN npm run build - FROM nginx:alpine -COPY --from=builder /app/dist /usr/share/nginx/html +COPY modules/frontend/dist /usr/share/nginx/html +COPY modules/frontend/nginx-default.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/Dockerfiles/Dockerfile_server b/Dockerfiles/Dockerfile_server deleted file mode 100644 index 87014ce..0000000 --- a/Dockerfiles/Dockerfile_server +++ /dev/null @@ -1,20 +0,0 @@ -FROM ubuntu:22.04 - -WORKDIR /app -COPY --chmod=755 nyanimedb /app -COPY templates /app/templates -EXPOSE 8080 -ENTRYPOINT ["/app/nyanimedb"] - -# FROM golang:1.25 AS builder - -# ARG VERSION=dev - -# WORKDIR /go/src/app -# COPY main.go . -# RUN go build -o main -ldflags=-X=main.version=${VERSION} main.go - -# FROM debian:buster-slim -# COPY --from=builder /go/src/app/main /go/bin/main -# ENV PATH="/go/bin:${PATH}" -# CMD ["main"] \ No newline at end of file diff --git a/api/api.gen.go b/api/api.gen.go new file mode 100644 index 0000000..427f5af --- /dev/null +++ b/api/api.gen.go @@ -0,0 +1,1213 @@ +// Package oapi provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT. +package oapi + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/oapi-codegen/runtime" + strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// Defines values for ReleaseSeason. +const ( + Fall ReleaseSeason = "fall" + Spring ReleaseSeason = "spring" + Summer ReleaseSeason = "summer" + Winter ReleaseSeason = "winter" +) + +// Defines values for TitleSort. +const ( + Id TitleSort = "id" + Rating TitleSort = "rating" + Views TitleSort = "views" + Year TitleSort = "year" +) + +// Defines values for TitleStatus. +const ( + TitleStatusFinished TitleStatus = "finished" + TitleStatusOngoing TitleStatus = "ongoing" + TitleStatusPlanned TitleStatus = "planned" +) + +// Defines values for UserTitleStatus. +const ( + UserTitleStatusDropped UserTitleStatus = "dropped" + UserTitleStatusFinished UserTitleStatus = "finished" + UserTitleStatusInProgress UserTitleStatus = "in-progress" + UserTitleStatusPlanned UserTitleStatus = "planned" +) + +// Image defines model for Image. +type Image struct { + Id *int64 `json:"id,omitempty"` + ImagePath *string `json:"image_path,omitempty"` + StorageType *string `json:"storage_type,omitempty"` +} + +// ReleaseSeason Title release season +type ReleaseSeason string + +// Studio defines model for Studio. +type Studio struct { + Description *string `json:"description,omitempty"` + Id int64 `json:"id"` + Name string `json:"name"` + Poster *Image `json:"poster,omitempty"` +} + +// Tag A localized tag: keys are language codes (ISO 639-1), values are tag names +type Tag map[string]string + +// Tags Array of localized tags +type Tags = []Tag + +// Title defines model for Title. +type Title struct { + EpisodesAired *int32 `json:"episodes_aired,omitempty"` + EpisodesAll *int32 `json:"episodes_all,omitempty"` + EpisodesLen *map[string]float64 `json:"episodes_len,omitempty"` + + // Id Unique title ID (primary key) + Id int64 `json:"id"` + Poster *Image `json:"poster,omitempty"` + Rating *float64 `json:"rating,omitempty"` + RatingCount *int32 `json:"rating_count,omitempty"` + + // ReleaseSeason Title release season + ReleaseSeason *ReleaseSeason `json:"release_season,omitempty"` + ReleaseYear *int32 `json:"release_year,omitempty"` + Studio *Studio `json:"studio,omitempty"` + + // Tags Array of localized tags + Tags Tags `json:"tags"` + + // TitleNames Localized titles. Key = language (ISO 639-1), value = list of names + TitleNames map[string][]string `json:"title_names"` + + // TitleStatus Title status + TitleStatus *TitleStatus `json:"title_status,omitempty"` + AdditionalProperties map[string]interface{} `json:"-"` +} + +// TitleSort Title sort order +type TitleSort string + +// TitleStatus Title status +type TitleStatus string + +// User defines model for User. +type User struct { + // AvatarId ID of the user avatar (references images table) + AvatarId *int64 `json:"avatar_id"` + + // CreationDate Timestamp when the user was created + CreationDate *time.Time `json:"creation_date,omitempty"` + + // DispName Display name + DispName *string `json:"disp_name,omitempty"` + + // Id Unique user ID (primary key) + Id *int64 `json:"id,omitempty"` + + // Mail User email + Mail *openapi_types.Email `json:"mail,omitempty"` + + // Nickname Username (alphanumeric + _ or -) + Nickname string `json:"nickname"` + + // UserDesc User description + UserDesc *string `json:"user_desc,omitempty"` +} + +// UserTitle defines model for UserTitle. +type UserTitle struct { + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` + + // Status User's title status + Status UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` + AdditionalProperties map[string]interface{} `json:"-"` +} + +// UserTitleStatus User's title status +type UserTitleStatus string + +// Cursor defines model for cursor. +type Cursor = string + +// GetTitlesParams defines parameters for GetTitles. +type GetTitlesParams struct { + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Sort *TitleSort `form:"sort,omitempty" json:"sort,omitempty"` + SortForward *bool `form:"sort_forward,omitempty" json:"sort_forward,omitempty"` + Word *string `form:"word,omitempty" json:"word,omitempty"` + Status *TitleStatus `form:"status,omitempty" json:"status,omitempty"` + Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` + ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` + ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` + Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int32 `form:"offset,omitempty" json:"offset,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// GetTitlesTitleIdParams defines parameters for GetTitlesTitleId. +type GetTitlesTitleIdParams struct { + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// GetUsersUserIdParams defines parameters for GetUsersUserId. +type GetUsersUserIdParams struct { + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. +type GetUsersUserIdTitlesParams struct { + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Word *string `form:"word,omitempty" json:"word,omitempty"` + Status *TitleStatus `form:"status,omitempty" json:"status,omitempty"` + WatchStatus *UserTitleStatus `form:"watch_status,omitempty" json:"watch_status,omitempty"` + Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` + ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` + ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` + Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// Getter for additional properties for Title. Returns the specified +// element and whether it was found +func (a Title) Get(fieldName string) (value interface{}, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for Title +func (a *Title) Set(fieldName string, value interface{}) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]interface{}) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for Title to handle AdditionalProperties +func (a *Title) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["episodes_aired"]; found { + err = json.Unmarshal(raw, &a.EpisodesAired) + if err != nil { + return fmt.Errorf("error reading 'episodes_aired': %w", err) + } + delete(object, "episodes_aired") + } + + if raw, found := object["episodes_all"]; found { + err = json.Unmarshal(raw, &a.EpisodesAll) + if err != nil { + return fmt.Errorf("error reading 'episodes_all': %w", err) + } + delete(object, "episodes_all") + } + + if raw, found := object["episodes_len"]; found { + err = json.Unmarshal(raw, &a.EpisodesLen) + if err != nil { + return fmt.Errorf("error reading 'episodes_len': %w", err) + } + delete(object, "episodes_len") + } + + if raw, found := object["id"]; found { + err = json.Unmarshal(raw, &a.Id) + if err != nil { + return fmt.Errorf("error reading 'id': %w", err) + } + delete(object, "id") + } + + if raw, found := object["poster"]; found { + err = json.Unmarshal(raw, &a.Poster) + if err != nil { + return fmt.Errorf("error reading 'poster': %w", err) + } + delete(object, "poster") + } + + if raw, found := object["rating"]; found { + err = json.Unmarshal(raw, &a.Rating) + if err != nil { + return fmt.Errorf("error reading 'rating': %w", err) + } + delete(object, "rating") + } + + if raw, found := object["rating_count"]; found { + err = json.Unmarshal(raw, &a.RatingCount) + if err != nil { + return fmt.Errorf("error reading 'rating_count': %w", err) + } + delete(object, "rating_count") + } + + if raw, found := object["release_season"]; found { + err = json.Unmarshal(raw, &a.ReleaseSeason) + if err != nil { + return fmt.Errorf("error reading 'release_season': %w", err) + } + delete(object, "release_season") + } + + if raw, found := object["release_year"]; found { + err = json.Unmarshal(raw, &a.ReleaseYear) + if err != nil { + return fmt.Errorf("error reading 'release_year': %w", err) + } + delete(object, "release_year") + } + + if raw, found := object["studio"]; found { + err = json.Unmarshal(raw, &a.Studio) + if err != nil { + return fmt.Errorf("error reading 'studio': %w", err) + } + delete(object, "studio") + } + + if raw, found := object["tags"]; found { + err = json.Unmarshal(raw, &a.Tags) + if err != nil { + return fmt.Errorf("error reading 'tags': %w", err) + } + delete(object, "tags") + } + + if raw, found := object["title_names"]; found { + err = json.Unmarshal(raw, &a.TitleNames) + if err != nil { + return fmt.Errorf("error reading 'title_names': %w", err) + } + delete(object, "title_names") + } + + if raw, found := object["title_status"]; found { + err = json.Unmarshal(raw, &a.TitleStatus) + if err != nil { + return fmt.Errorf("error reading 'title_status': %w", err) + } + delete(object, "title_status") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]interface{}) + for fieldName, fieldBuf := range object { + var fieldVal interface{} + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for Title to handle AdditionalProperties +func (a Title) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + if a.EpisodesAired != nil { + object["episodes_aired"], err = json.Marshal(a.EpisodesAired) + if err != nil { + return nil, fmt.Errorf("error marshaling 'episodes_aired': %w", err) + } + } + + if a.EpisodesAll != nil { + object["episodes_all"], err = json.Marshal(a.EpisodesAll) + if err != nil { + return nil, fmt.Errorf("error marshaling 'episodes_all': %w", err) + } + } + + if a.EpisodesLen != nil { + object["episodes_len"], err = json.Marshal(a.EpisodesLen) + if err != nil { + return nil, fmt.Errorf("error marshaling 'episodes_len': %w", err) + } + } + + object["id"], err = json.Marshal(a.Id) + if err != nil { + return nil, fmt.Errorf("error marshaling 'id': %w", err) + } + + if a.Poster != nil { + object["poster"], err = json.Marshal(a.Poster) + if err != nil { + return nil, fmt.Errorf("error marshaling 'poster': %w", err) + } + } + + if a.Rating != nil { + object["rating"], err = json.Marshal(a.Rating) + if err != nil { + return nil, fmt.Errorf("error marshaling 'rating': %w", err) + } + } + + if a.RatingCount != nil { + object["rating_count"], err = json.Marshal(a.RatingCount) + if err != nil { + return nil, fmt.Errorf("error marshaling 'rating_count': %w", err) + } + } + + if a.ReleaseSeason != nil { + object["release_season"], err = json.Marshal(a.ReleaseSeason) + if err != nil { + return nil, fmt.Errorf("error marshaling 'release_season': %w", err) + } + } + + if a.ReleaseYear != nil { + object["release_year"], err = json.Marshal(a.ReleaseYear) + if err != nil { + return nil, fmt.Errorf("error marshaling 'release_year': %w", err) + } + } + + if a.Studio != nil { + object["studio"], err = json.Marshal(a.Studio) + if err != nil { + return nil, fmt.Errorf("error marshaling 'studio': %w", err) + } + } + + object["tags"], err = json.Marshal(a.Tags) + if err != nil { + return nil, fmt.Errorf("error marshaling 'tags': %w", err) + } + + object["title_names"], err = json.Marshal(a.TitleNames) + if err != nil { + return nil, fmt.Errorf("error marshaling 'title_names': %w", err) + } + + if a.TitleStatus != nil { + object["title_status"], err = json.Marshal(a.TitleStatus) + if err != nil { + return nil, fmt.Errorf("error marshaling 'title_status': %w", err) + } + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + +// Getter for additional properties for UserTitle. Returns the specified +// element and whether it was found +func (a UserTitle) Get(fieldName string) (value interface{}, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for UserTitle +func (a *UserTitle) Set(fieldName string, value interface{}) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]interface{}) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for UserTitle to handle AdditionalProperties +func (a *UserTitle) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["ctime"]; found { + err = json.Unmarshal(raw, &a.Ctime) + if err != nil { + return fmt.Errorf("error reading 'ctime': %w", err) + } + delete(object, "ctime") + } + + if raw, found := object["rate"]; found { + err = json.Unmarshal(raw, &a.Rate) + if err != nil { + return fmt.Errorf("error reading 'rate': %w", err) + } + delete(object, "rate") + } + + if raw, found := object["review_id"]; found { + err = json.Unmarshal(raw, &a.ReviewId) + if err != nil { + return fmt.Errorf("error reading 'review_id': %w", err) + } + delete(object, "review_id") + } + + if raw, found := object["status"]; found { + err = json.Unmarshal(raw, &a.Status) + if err != nil { + return fmt.Errorf("error reading 'status': %w", err) + } + delete(object, "status") + } + + if raw, found := object["title_id"]; found { + err = json.Unmarshal(raw, &a.TitleId) + if err != nil { + return fmt.Errorf("error reading 'title_id': %w", err) + } + delete(object, "title_id") + } + + if raw, found := object["user_id"]; found { + err = json.Unmarshal(raw, &a.UserId) + if err != nil { + return fmt.Errorf("error reading 'user_id': %w", err) + } + delete(object, "user_id") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]interface{}) + for fieldName, fieldBuf := range object { + var fieldVal interface{} + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for UserTitle to handle AdditionalProperties +func (a UserTitle) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + if a.Ctime != nil { + object["ctime"], err = json.Marshal(a.Ctime) + if err != nil { + return nil, fmt.Errorf("error marshaling 'ctime': %w", err) + } + } + + if a.Rate != nil { + object["rate"], err = json.Marshal(a.Rate) + if err != nil { + return nil, fmt.Errorf("error marshaling 'rate': %w", err) + } + } + + if a.ReviewId != nil { + object["review_id"], err = json.Marshal(a.ReviewId) + if err != nil { + return nil, fmt.Errorf("error marshaling 'review_id': %w", err) + } + } + + object["status"], err = json.Marshal(a.Status) + if err != nil { + return nil, fmt.Errorf("error marshaling 'status': %w", err) + } + + object["title_id"], err = json.Marshal(a.TitleId) + if err != nil { + return nil, fmt.Errorf("error marshaling 'title_id': %w", err) + } + + object["user_id"], err = json.Marshal(a.UserId) + if err != nil { + return nil, fmt.Errorf("error marshaling 'user_id': %w", err) + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Get titles + // (GET /titles) + GetTitles(c *gin.Context, params GetTitlesParams) + // Get title description + // (GET /titles/{title_id}) + GetTitlesTitleId(c *gin.Context, titleId int64, params GetTitlesTitleIdParams) + // Get user info + // (GET /users/{user_id}) + GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) + // Get user titles + // (GET /users/{user_id}/titles/) + GetUsersUserIdTitles(c *gin.Context, userId string, params GetUsersUserIdTitlesParams) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// GetTitles operation middleware +func (siw *ServerInterfaceWrapper) GetTitles(c *gin.Context) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetTitlesParams + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameter("form", true, false, "cursor", c.Request.URL.Query(), ¶ms.Cursor) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter cursor: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "sort" ------------- + + err = runtime.BindQueryParameter("form", true, false, "sort", c.Request.URL.Query(), ¶ms.Sort) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter sort: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "sort_forward" ------------- + + err = runtime.BindQueryParameter("form", true, false, "sort_forward", c.Request.URL.Query(), ¶ms.SortForward) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter sort_forward: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "word" ------------- + + err = runtime.BindQueryParameter("form", true, false, "word", c.Request.URL.Query(), ¶ms.Word) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter word: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameter("form", true, false, "status", c.Request.URL.Query(), ¶ms.Status) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter status: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "rating" ------------- + + err = runtime.BindQueryParameter("form", true, false, "rating", c.Request.URL.Query(), ¶ms.Rating) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter rating: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "release_year" ------------- + + err = runtime.BindQueryParameter("form", true, false, "release_year", c.Request.URL.Query(), ¶ms.ReleaseYear) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter release_year: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "release_season" ------------- + + err = runtime.BindQueryParameter("form", true, false, "release_season", c.Request.URL.Query(), ¶ms.ReleaseSeason) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter release_season: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTitles(c, params) +} + +// GetTitlesTitleId operation middleware +func (siw *ServerInterfaceWrapper) GetTitlesTitleId(c *gin.Context) { + + var err error + + // ------------- Path parameter "title_id" ------------- + var titleId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetTitlesTitleIdParams + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTitlesTitleId(c, titleId, params) +} + +// GetUsersUserId operation middleware +func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetUsersUserIdParams + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetUsersUserId(c, userId, params) +} + +// GetUsersUserIdTitles operation middleware +func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetUsersUserIdTitlesParams + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameter("form", true, false, "cursor", c.Request.URL.Query(), ¶ms.Cursor) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter cursor: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "word" ------------- + + err = runtime.BindQueryParameter("form", true, false, "word", c.Request.URL.Query(), ¶ms.Word) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter word: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameter("form", true, false, "status", c.Request.URL.Query(), ¶ms.Status) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter status: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "watch_status" ------------- + + err = runtime.BindQueryParameter("form", true, false, "watch_status", c.Request.URL.Query(), ¶ms.WatchStatus) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter watch_status: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "rating" ------------- + + err = runtime.BindQueryParameter("form", true, false, "rating", c.Request.URL.Query(), ¶ms.Rating) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter rating: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "release_year" ------------- + + err = runtime.BindQueryParameter("form", true, false, "release_year", c.Request.URL.Query(), ¶ms.ReleaseYear) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter release_year: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "release_season" ------------- + + err = runtime.BindQueryParameter("form", true, false, "release_season", c.Request.URL.Query(), ¶ms.ReleaseSeason) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter release_season: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetUsersUserIdTitles(c, userId, params) +} + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + errorHandler := options.ErrorHandler + if errorHandler == nil { + errorHandler = func(c *gin.Context, err error, statusCode int) { + c.JSON(statusCode, gin.H{"msg": err.Error()}) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandler: errorHandler, + } + + router.GET(options.BaseURL+"/titles", wrapper.GetTitles) + router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitlesTitleId) + router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) + router.GET(options.BaseURL+"/users/:user_id/titles/", wrapper.GetUsersUserIdTitles) +} + +type GetTitlesRequestObject struct { + Params GetTitlesParams +} + +type GetTitlesResponseObject interface { + VisitGetTitlesResponse(w http.ResponseWriter) error +} + +type GetTitles200JSONResponse []Title + +func (response GetTitles200JSONResponse) VisitGetTitlesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTitles204Response struct { +} + +func (response GetTitles204Response) VisitGetTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetTitles400Response struct { +} + +func (response GetTitles400Response) VisitGetTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type GetTitles500Response struct { +} + +func (response GetTitles500Response) VisitGetTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type GetTitlesTitleIdRequestObject struct { + TitleId int64 `json:"title_id"` + Params GetTitlesTitleIdParams +} + +type GetTitlesTitleIdResponseObject interface { + VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error +} + +type GetTitlesTitleId200JSONResponse Title + +func (response GetTitlesTitleId200JSONResponse) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTitlesTitleId204Response struct { +} + +func (response GetTitlesTitleId204Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetTitlesTitleId400Response struct { +} + +func (response GetTitlesTitleId400Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type GetTitlesTitleId404Response struct { +} + +func (response GetTitlesTitleId404Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type GetTitlesTitleId500Response struct { +} + +func (response GetTitlesTitleId500Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type GetUsersUserIdRequestObject struct { + UserId string `json:"user_id"` + Params GetUsersUserIdParams +} + +type GetUsersUserIdResponseObject interface { + VisitGetUsersUserIdResponse(w http.ResponseWriter) error +} + +type GetUsersUserId200JSONResponse User + +func (response GetUsersUserId200JSONResponse) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUsersUserId400Response struct { +} + +func (response GetUsersUserId400Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type GetUsersUserId404Response struct { +} + +func (response GetUsersUserId404Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type GetUsersUserId500Response struct { +} + +func (response GetUsersUserId500Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type GetUsersUserIdTitlesRequestObject struct { + UserId string `json:"user_id"` + Params GetUsersUserIdTitlesParams +} + +type GetUsersUserIdTitlesResponseObject interface { + VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error +} + +type GetUsersUserIdTitles200JSONResponse []UserTitle + +func (response GetUsersUserIdTitles200JSONResponse) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUsersUserIdTitles204Response struct { +} + +func (response GetUsersUserIdTitles204Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetUsersUserIdTitles400Response struct { +} + +func (response GetUsersUserIdTitles400Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type GetUsersUserIdTitles500Response struct { +} + +func (response GetUsersUserIdTitles500Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Get titles + // (GET /titles) + GetTitles(ctx context.Context, request GetTitlesRequestObject) (GetTitlesResponseObject, error) + // Get title description + // (GET /titles/{title_id}) + GetTitlesTitleId(ctx context.Context, request GetTitlesTitleIdRequestObject) (GetTitlesTitleIdResponseObject, error) + // Get user info + // (GET /users/{user_id}) + GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) + // Get user titles + // (GET /users/{user_id}/titles/) + GetUsersUserIdTitles(ctx context.Context, request GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error) +} + +type StrictHandlerFunc = strictgin.StrictGinHandlerFunc +type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// GetTitles operation middleware +func (sh *strictHandler) GetTitles(ctx *gin.Context, params GetTitlesParams) { + var request GetTitlesRequestObject + + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetTitles(ctx, request.(GetTitlesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTitles") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetTitlesResponseObject); ok { + if err := validResponse.VisitGetTitlesResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetTitlesTitleId operation middleware +func (sh *strictHandler) GetTitlesTitleId(ctx *gin.Context, titleId int64, params GetTitlesTitleIdParams) { + var request GetTitlesTitleIdRequestObject + + request.TitleId = titleId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetTitlesTitleId(ctx, request.(GetTitlesTitleIdRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTitlesTitleId") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetTitlesTitleIdResponseObject); ok { + if err := validResponse.VisitGetTitlesTitleIdResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetUsersUserId operation middleware +func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { + var request GetUsersUserIdRequestObject + + request.UserId = userId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUsersUserId(ctx, request.(GetUsersUserIdRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUsersUserId") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetUsersUserIdResponseObject); ok { + if err := validResponse.VisitGetUsersUserIdResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetUsersUserIdTitles operation middleware +func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, params GetUsersUserIdTitlesParams) { + var request GetUsersUserIdTitlesRequestObject + + request.UserId = userId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUsersUserIdTitles(ctx, request.(GetUsersUserIdTitlesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUsersUserIdTitles") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetUsersUserIdTitlesResponseObject); ok { + if err := validResponse.VisitGetUsersUserIdTitlesResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/api/oapi-codegen.yaml b/api/oapi-codegen.yaml new file mode 100644 index 0000000..32e029a --- /dev/null +++ b/api/oapi-codegen.yaml @@ -0,0 +1,6 @@ +package: oapi +generate: + strict-server: true + gin-server: true + models: true +output: api/api.gen.go \ No newline at end of file diff --git a/api/openapi.yaml b/api/openapi.yaml new file mode 100644 index 0000000..a6ae12c --- /dev/null +++ b/api/openapi.yaml @@ -0,0 +1,837 @@ +openapi: 3.1.1 +info: + title: Titles, Users, Reviews, Tags, and Media API + version: 1.0.0 + +servers: + - url: /api/v1 + +paths: + /titles: + get: + summary: Get titles + parameters: + - $ref: '#/components/parameters/cursor' + - $ref: '#/components/parameters/title_sort' + - in: query + name: sort_forward + default: true + schema: + type: boolean + - in: query + name: word + schema: + type: string + - in: query + name: status + schema: + $ref: '#/components/schemas/TitleStatus' + - in: query + name: rating + schema: + type: number + format: double + - in: query + name: release_year + schema: + type: integer + format: int32 + - in: query + name: release_season + schema: + $ref: '#/components/schemas/ReleaseSeason' + - in: query + name: limit + schema: + type: integer + format: int32 + default: 10 + - in: query + name: offset + schema: + type: integer + format: int32 + default: 0 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: List of titles + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Title' + '204': + description: No titles found + '400': + description: Request params are not correct + '500': + description: Unknown server error + + /titles/{title_id}: + get: + summary: Get title description + parameters: + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: Title description + content: + application/json: + schema: + $ref: '#/components/schemas/Title' + '404': + description: Title not found + '400': + description: Request params are not correct + '500': + description: Unknown server error + '204': + description: No title found + +# patch: +# summary: Update title info +# parameters: +# - in: path +# name: title_id +# required: true +# schema: +# type: string +# requestBody: +# required: true +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/Title' +# responses: +# '200': +# description: Update result +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string +# user_json: +# $ref: '#/components/schemas/User' + +# /titles/{title_id}/reviews: +# get: +# summary: Get title reviews +# parameters: +# - in: path +# name: title_id +# required: true +# schema: +# type: string +# - in: query +# name: limit +# schema: +# type: integer +# default: 10 +# - in: query +# name: offset +# schema: +# type: integer +# default: 0 +# responses: +# '200': +# description: List of reviews +# content: +# application/json: +# schema: +# type: array +# items: +# $ref: '#/components/schemas/Review' +# '204': +# description: No reviews found + + /users/{user_id}: + get: + summary: Get user info + parameters: + - in: path + name: user_id + required: true + schema: + type: string + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: User info + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + description: User not found + '400': + description: Request params are not correct + '500': + description: Unknown server error + + # patch: + # summary: Update user + # parameters: + # - in: path + # name: user_id + # required: true + # schema: + # type: string + # requestBody: + # required: true + # content: + # application/json: + # schema: + # $ref: '#/components/schemas/User' + # responses: + # '200': + # description: Update result + # content: + # application/json: + # schema: + # type: object + # properties: + # success: + # type: boolean + # error: + # type: string + + # delete: + # summary: Delete user + # parameters: + # - in: path + # name: user_id + # required: true + # schema: + # type: string + # responses: + # '200': + # description: Delete result + # content: + # application/json: + # schema: + # type: object + # properties: + # success: + # type: boolean + # error: + # type: string + + # /users: + # get: + # summary: Search user + # parameters: + # - in: query + # name: query + # schema: + # type: string + # - in: query + # name: fields + # schema: + # type: string + # responses: + # '200': + # description: List of users + # content: + # application/json: + # schema: + # type: array + # items: + # $ref: '#/components/schemas/User' + + # post: + # summary: Add new user + # requestBody: + # required: true + # content: + # application/json: + # schema: + # $ref: '#/components/schemas/User' + # responses: + # '200': + # description: Add result + # content: + # application/json: + # schema: + # type: object + # properties: + # success: + # type: boolean + # error: + # type: string + # user_json: + # $ref: '#/components/schemas/User' + + /users/{user_id}/titles/: + get: + summary: Get user titles + parameters: + - $ref: '#/components/parameters/cursor' + - in: path + name: user_id + required: true + schema: + type: string + - in: query + name: word + schema: + type: string + - in: query + name: status + schema: + $ref: '#/components/schemas/TitleStatus' + - in: query + name: watch_status + schema: + $ref: '#/components/schemas/UserTitleStatus' + - in: query + name: rating + schema: + type: number + format: double + - in: query + name: release_year + schema: + type: integer + format: int32 + - in: query + name: release_season + schema: + $ref: '#/components/schemas/ReleaseSeason' + - in: query + name: limit + schema: + type: integer + format: int32 + default: 10 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: List of user titles + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UserTitle' + '204': + description: No titles found + '400': + description: Request params are not correct + '500': + description: Unknown server error + +# post: +# summary: Add user title +# parameters: +# - in: path +# name: user_id +# required: true +# schema: +# type: string +# requestBody: +# required: true +# content: +# application/json: +# schema: +# type: object +# properties: +# title_id: +# type: string +# status: +# type: string +# responses: +# '200': +# description: Add result +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string + +# patch: +# summary: Update user title +# parameters: +# - in: path +# name: user_id +# required: true +# schema: +# type: string +# requestBody: +# required: true +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/UserTitle' +# responses: +# '200': +# description: Update result +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string + +# delete: +# summary: Delete user title +# parameters: +# - in: path +# name: user_id +# required: true +# schema: +# type: string +# - in: query +# name: title_id +# schema: +# type: string +# responses: +# '200': +# description: Delete result +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string + +# /users/{user_id}/reviews: +# get: +# summary: Get user reviews +# parameters: +# - in: path +# name: user_id +# required: true +# schema: +# type: string +# - in: query +# name: limit +# schema: +# type: integer +# default: 10 +# - in: query +# name: offset +# schema: +# type: integer +# default: 0 +# responses: +# '200': +# description: List of reviews +# content: +# application/json: +# schema: +# type: array +# items: +# $ref: '#/components/schemas/Review' + +# /reviews: +# post: +# summary: Add review +# requestBody: +# required: true +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/Review' +# responses: +# '200': +# description: Add result +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string + +# /reviews/{review_id}: +# patch: +# summary: Update review +# parameters: +# - in: path +# name: review_id +# required: true +# schema: +# type: string +# requestBody: +# required: true +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/Review' +# responses: +# '200': +# description: Update result +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string +# delete: +# summary: Delete review +# parameters: +# - in: path +# name: review_id +# required: true +# schema: +# type: string +# responses: +# '200': +# description: Delete result +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string + +# /tags: +# get: +# summary: Get tags +# parameters: +# - in: query +# name: limit +# schema: +# type: integer +# default: 10 +# - in: query +# name: offset +# schema: +# type: integer +# default: 0 +# - in: query +# name: fields +# schema: +# type: string +# responses: +# '200': +# description: List of tags +# content: +# application/json: +# schema: +# type: array +# items: +# $ref: '#/components/schemas/Tag' + +# /media: +# post: +# summary: Upload image +# responses: +# '200': +# description: Upload result +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string +# image_id: +# type: string + +# get: +# summary: Get image path +# parameters: +# - in: query +# name: image_id +# required: true +# schema: +# type: string +# responses: +# '200': +# description: Image path +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string +# image_path: +# type: string + +components: + parameters: + cursor: # example base64( {id: 1, param: 2019}) + in: query + name: cursor + required: false + schema: + type: string + + title_sort: + in: query + name: sort + required: false + schema: + $ref: '#/components/schemas/TitleSort' + + schemas: + TitleSort: + type: string + description: Title sort order + default: id + enum: + - id + - year + - rating + - views + + Image: + type: object + properties: + id: + type: integer + format: int64 + storage_type: + type: string + image_path: + type: string + + TitleStatus: + type: string + description: Title status + enum: + - finished + - ongoing + - planned + + ReleaseSeason: + type: string + description: Title release season + enum: + - winter + - spring + - summer + - fall + + UserTitleStatus: + type: string + description: User's title status + enum: + - finished + - planned + - dropped + - in-progress + + Review: + type: object + additionalProperties: true + + Tag: + type: object + description: "A localized tag: keys are language codes (ISO 639-1), values are tag names" + additionalProperties: + type: string + example: + en: "Shojo" + ru: "Сёдзё" + ja: "少女" + + Tags: + type: array + description: "Array of localized tags" + items: + $ref: '#/components/schemas/Tag' + example: + - en: "Shojo" + ru: "Сёдзё" + ja: "少女" + - en: "Shounen" + ru: "Сёнен" + ja: "少年" + + Studio: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + poster: + $ref: '#/components/schemas/Image' + description: + type: string + + + Title: + type: object + required: + - id + - title_names + - tags + properties: + id: + type: integer + format: int64 + description: Unique title ID (primary key) + example: 1 + title_names: + type: object + description: "Localized titles. Key = language (ISO 639-1), value = list of names" + additionalProperties: + type: array + items: + type: string + example: "Attack on Titan" + minItems: 1 + example: ["Attack on Titan", "AoT"] + example: + en: ["Attack on Titan", "AoT"] + ru: ["Атака титанов", "Титаны"] + ja: ["進撃の巨人"] + studio: + $ref: '#/components/schemas/Studio' + tags: + $ref: '#/components/schemas/Tags' + poster: + $ref: '#/components/schemas/Image' + title_status: + $ref: '#/components/schemas/TitleStatus' + rating: + type: number + format: double + rating_count: + type: integer + format: int32 + release_year: + type: integer + format: int32 + release_season: + $ref: '#/components/schemas/ReleaseSeason' + episodes_aired: + type: integer + format: int32 + episodes_all: + type: integer + format: int32 + episodes_len: + type: object + additionalProperties: + type: number + format: double + additionalProperties: true + + User: + type: object + properties: + id: + type: integer + format: int64 + description: Unique user ID (primary key) + example: 1 + avatar_id: + type: integer + format: int64 + description: ID of the user avatar (references images table) + nullable: true + example: null + mail: + type: string + format: email + description: User email + example: "john.doe@example.com" + nickname: + type: string + description: Username (alphanumeric + _ or -) + maxLength: 16 + example: "john_doe_42" + disp_name: + type: string + description: Display name + maxLength: 32 + example: "John Doe" + user_desc: + type: string + description: User description + maxLength: 512 + example: "Just a regular user." + creation_date: + type: string + format: date-time + description: Timestamp when the user was created + example: "2025-10-10T23:45:47.908073Z" + required: + - user_id + - nickname + # - creation_date + + UserTitle: + type: object + required: + - user_id + - title_id + - status + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time + additionalProperties: true diff --git a/deploy/buildx.sh b/deploy/buildx.sh index 96e0fe4..8c19bc9 100644 --- a/deploy/buildx.sh +++ b/deploy/buildx.sh @@ -1,2 +1,7 @@ -#!/bin/bash -docker buildx build --platform linux/amd64 -t meowgit.nekoea.red/nihonium/forgejo-runner:latest -f ./Dockerfiles/Dockerfile_forgejo-runner . --push \ No newline at end of file +#!/usr/bin/env bash + +export BACKEND_API_BASE_URL="http://127.0.0.1:8080" + +docker buildx build --platform linux/amd64 -t meowgit.nekoea.red/nihonium/forgejo-runner:latest -f ./Dockerfiles/Dockerfile_forgejo-runner . --push +docker buildx build --build-arg VITE_BACKEND_API_BASE_URL=${BACKEND_API_BASE_URL} --platform linux/amd64 -t meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest -f .\Dockerfiles\Dockerfile_frontend . --push +docker buildx build --platform linux/amd64 -t meowgit.nekoea.red/nihonium/nyanimedb-backend:latest -f .\Dockerfiles\Dockerfile_backend . --push \ No newline at end of file diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 266f9d5..1a96253 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -10,7 +10,7 @@ services: ports: - "${POSTGRES_PORT}:5432" volumes: - - postgres_data:/var/lib/postgresql/data + - postgres_data:/var/lib/postgresql # pgadmin: # image: dpage/pgadmin4:${PGADMIN_VERSION} @@ -32,6 +32,7 @@ services: restart: always environment: LOG_LEVEL: ${LOG_LEVEL} + DATABASE_URL: ${DATABASE_URL} ports: - "8080:8080" depends_on: diff --git a/deploy/generate.sh b/deploy/generate.sh new file mode 100644 index 0000000..d7d94a2 --- /dev/null +++ b/deploy/generate.sh @@ -0,0 +1,3 @@ +npx openapi-typescript-codegen --input ..\..\api\openapi.yaml --output ./src/api --client axios +oapi-codegen --config=api/oapi-codegen.yaml .\api\openapi.yaml +sqlc generate -f .\sql\sqlc.yaml \ No newline at end of file diff --git a/modules/server/go.mod b/go.mod similarity index 66% rename from modules/server/go.mod rename to go.mod index cbdfb8b..7c34aeb 100644 --- a/modules/server/go.mod +++ b/go.mod @@ -1,26 +1,37 @@ -module nyanimedb-server +module nyanimedb go 1.25.0 require ( + github.com/gin-contrib/cors v1.7.6 + github.com/gin-gonic/gin v1.11.0 + github.com/jackc/pgx/v5 v5.7.6 + github.com/oapi-codegen/runtime v1.1.2 + github.com/pelletier/go-toml/v2 v2.2.4 + golang.org/x/crypto v0.40.0 +) + +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/gin-gonic/gin v1.11.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect @@ -28,7 +39,6 @@ require ( github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.40.0 // indirect golang.org/x/mod v0.25.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect diff --git a/modules/server/go.sum b/go.sum similarity index 67% rename from modules/server/go.sum rename to go.sum index cb0d745..121ca40 100644 --- a/modules/server/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= @@ -5,38 +9,60 @@ github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFos github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= +github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= +github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= @@ -44,6 +70,7 @@ github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQB github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -52,6 +79,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= @@ -68,7 +97,6 @@ golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= @@ -81,4 +109,5 @@ google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7I google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/modules/server/config.toml b/modules/backend/config.toml similarity index 100% rename from modules/server/config.toml rename to modules/backend/config.toml diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go new file mode 100644 index 0000000..b85d022 --- /dev/null +++ b/modules/backend/handlers/common.go @@ -0,0 +1,19 @@ +package handlers + +import ( + sqlc "nyanimedb/sql" + "strconv" +) + +type Server struct { + db *sqlc.Queries +} + +func NewServer(db *sqlc.Queries) Server { + return Server{db: db} +} + +func parseInt64(s string) (int32, error) { + i, err := strconv.ParseInt(s, 10, 64) + return int32(i), err +} diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go new file mode 100644 index 0000000..46ff982 --- /dev/null +++ b/modules/backend/handlers/titles.go @@ -0,0 +1,262 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + oapi "nyanimedb/api" + sqlc "nyanimedb/sql" + + "github.com/jackc/pgx/v5" + log "github.com/sirupsen/logrus" +) + +func Word2Sqlc(s *string) *string { + if s == nil || *s == "" { + return nil + } + + return s +} + +func TitleStatus2Sqlc(s *oapi.TitleStatus) (*sqlc.TitleStatusT, error) { + if s == nil { + return nil, nil + } + var t sqlc.TitleStatusT + switch *s { + case oapi.TitleStatusFinished: + t = sqlc.TitleStatusTFinished + case oapi.TitleStatusOngoing: + t = sqlc.TitleStatusTOngoing + case oapi.TitleStatusPlanned: + t = sqlc.TitleStatusTPlanned + default: + return nil, fmt.Errorf("unexpected tittle status: %s", *s) + } + return &t, nil +} + +func ReleaseSeason2sqlc(s *oapi.ReleaseSeason) (*sqlc.ReleaseSeasonT, error) { + if s == nil { + return nil, nil + } + var t sqlc.ReleaseSeasonT + switch *s { + case oapi.Winter: + t = sqlc.ReleaseSeasonTWinter + case oapi.Spring: + t = sqlc.ReleaseSeasonTSpring + case oapi.Summer: + t = sqlc.ReleaseSeasonTSummer + case oapi.Fall: + t = sqlc.ReleaseSeasonTFall + default: + return nil, fmt.Errorf("unexpected release season: %s", *s) + } + return &t, nil +} + +func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, error) { + + sqlc_title_tags, err := s.db.GetTitleTags(ctx, id) + if err != nil { + if err == pgx.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("query GetTitleTags: %v", err) + } + + oapi_tag_names := make(oapi.Tags, 1) + for _, title_tag := range sqlc_title_tags { + oapi_tag_name := make(map[string]string, 1) + err = json.Unmarshal(title_tag, &oapi_tag_name) + if err != nil { + return nil, fmt.Errorf("unmarshalling title_tag: %v", err) + } + oapi_tag_names = append(oapi_tag_names, oapi_tag_name) + } + + return oapi_tag_names, nil +} + +func (s Server) GetImage(ctx context.Context, id int64) (*oapi.Image, error) { + + var oapi_image oapi.Image + + sqlc_image, err := s.db.GetImageByID(ctx, id) + if err != nil { + if err == pgx.ErrNoRows { + return nil, nil //todo: error reference in db + } + return &oapi_image, fmt.Errorf("query GetImageByID: %v", err) + } + + //can cast and dont use brain cause all this fields required in image table + oapi_image.Id = &sqlc_image.ID + oapi_image.ImagePath = &sqlc_image.ImagePath + storageTypeStr := string(sqlc_image.StorageType) + oapi_image.StorageType = &storageTypeStr + + return &oapi_image, nil +} + +func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { + + var oapi_studio oapi.Studio + + sqlc_studio, err := s.db.GetStudioByID(ctx, id) + if err != nil { + if err == pgx.ErrNoRows { + return nil, nil + } + return &oapi_studio, fmt.Errorf("query GetStudioByID: %v", err) + } + + oapi_studio.Id = sqlc_studio.ID + oapi_studio.Name = sqlc_studio.StudioName + oapi_studio.Description = sqlc_studio.StudioDesc + + if sqlc_studio.IllustID == nil { + return &oapi_studio, nil + } + oapi_illust, err := s.GetImage(ctx, *sqlc_studio.IllustID) + if err != nil { + return &oapi_studio, fmt.Errorf("GetImage: %v", err) + } + if oapi_illust != nil { + oapi_studio.Poster = oapi_illust + } + + return &oapi_studio, nil +} + +func (s Server) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, error) { + + var oapi_title oapi.Title + + title_names := make(map[string][]string, 1) + err := json.Unmarshal(title.TitleNames, &title_names) + if err != nil { + return oapi_title, fmt.Errorf("unmarshal TitleNames: %v", err) + } + + episodes_lens := make(map[string]float64, 1) + err = json.Unmarshal(title.EpisodesLen, &episodes_lens) + if err != nil { + return oapi_title, fmt.Errorf("unmarshal EpisodesLen: %v", err) + } + + oapi_tag_names, err := s.GetTagsByTitleId(ctx, title.ID) + if err != nil { + return oapi_title, fmt.Errorf("GetTagsByTitleId: %v", err) + } + if oapi_tag_names != nil { + oapi_title.Tags = oapi_tag_names + } + + if title.PosterID != nil { + oapi_image, err := s.GetImage(ctx, *title.PosterID) + if err != nil { + return oapi_title, fmt.Errorf("GetImage: %v", err) + } + if oapi_image != nil { + oapi_title.Poster = oapi_image + } + } + + oapi_studio, err := s.GetStudio(ctx, title.StudioID) + if err != nil { + return oapi_title, fmt.Errorf("GetStudio: %v", err) + } + if oapi_studio != nil { + oapi_title.Studio = oapi_studio + } + + if title.ReleaseSeason != nil { + rs := oapi.ReleaseSeason(*title.ReleaseSeason) + oapi_title.ReleaseSeason = &rs + } else { + oapi_title.ReleaseSeason = nil + } + + ts := oapi.TitleStatus(title.TitleStatus) + oapi_title.TitleStatus = &ts + + oapi_title.Id = title.ID + oapi_title.Rating = title.Rating + oapi_title.RatingCount = title.RatingCount + oapi_title.ReleaseYear = title.ReleaseYear + oapi_title.TitleNames = title_names + oapi_title.EpisodesAired = title.EpisodesAired + oapi_title.EpisodesAll = title.EpisodesAll + oapi_title.EpisodesLen = &episodes_lens + + return oapi_title, nil +} + +func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitleIdRequestObject) (oapi.GetTitlesTitleIdResponseObject, error) { + var oapi_title oapi.Title + + sqlc_title, err := s.db.GetTitleByID(ctx, request.TitleId) + if err != nil { + if err == pgx.ErrNoRows { + return oapi.GetTitlesTitleId204Response{}, nil + } + log.Errorf("%v", err) + return oapi.GetTitlesTitleId500Response{}, nil + } + + oapi_title, err = s.mapTitle(ctx, sqlc_title) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitlesTitleId500Response{}, nil + } + + return oapi.GetTitlesTitleId200JSONResponse(oapi_title), nil +} + +func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) { + opai_titles := make([]oapi.Title, 0) + + word := Word2Sqlc(request.Params.Word) + status, err := TitleStatus2Sqlc(request.Params.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitles400Response{}, err + } + season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitles400Response{}, err + } + // param = nil means it will not be used + titles, err := s.db.SearchTitles(ctx, sqlc.SearchTitlesParams{ + Word: word, + Status: status, + Rating: request.Params.Rating, + ReleaseYear: request.Params.ReleaseYear, + ReleaseSeason: season, + Offset: request.Params.Offset, + Limit: request.Params.Limit, + }) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitles500Response{}, nil + } + if len(titles) == 0 { + return oapi.GetTitles204Response{}, nil + } + + for _, title := range titles { + + t, err := s.mapTitle(ctx, title) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitles500Response{}, nil + } + opai_titles = append(opai_titles, t) + } + + return oapi.GetTitles200JSONResponse(opai_titles), nil +} diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go new file mode 100644 index 0000000..0fa903f --- /dev/null +++ b/modules/backend/handlers/users.go @@ -0,0 +1,83 @@ +package handlers + +import ( + "context" + oapi "nyanimedb/api" + sqlc "nyanimedb/sql" + "time" + + "github.com/jackc/pgx/v5" + "github.com/oapi-codegen/runtime/types" +) + +// type Server struct { +// db *sqlc.Queries +// } + +// func NewServer(db *sqlc.Queries) Server { +// return Server{db: db} +// } + +// func parseInt64(s string) (int32, error) { +// i, err := strconv.ParseInt(s, 10, 64) +// return int32(i), err +// } + +func mapUser(u sqlc.GetUserByIDRow) oapi.User { + return oapi.User{ + AvatarId: u.AvatarID, + CreationDate: &u.CreationDate, + DispName: u.DispName, + Id: &u.ID, + Mail: (*types.Email)(u.Mail), + Nickname: u.Nickname, + UserDesc: u.UserDesc, + } +} + +func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdRequestObject) (oapi.GetUsersUserIdResponseObject, error) { + userID, err := parseInt64(req.UserId) + if err != nil { + return oapi.GetUsersUserId404Response{}, nil + } + user, err := s.db.GetUserByID(context.TODO(), int64(userID)) + if err != nil { + if err == pgx.ErrNoRows { + return oapi.GetUsersUserId404Response{}, nil + } + return nil, err + } + return oapi.GetUsersUserId200JSONResponse(mapUser(user)), nil +} + +func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersUserIdTitlesRequestObject) (oapi.GetUsersUserIdTitlesResponseObject, error) { + + var rate int32 = 9 + var review_id int64 = 3 + time := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) + + var userTitles = []oapi.UserTitle{ + { + UserId: 101, + TitleId: 2001, + Status: oapi.UserTitleStatusFinished, + Rate: &rate, + Ctime: &time, + }, + { + UserId: 102, + TitleId: 2002, + Status: oapi.UserTitleStatusInProgress, + ReviewId: &review_id, + Ctime: &time, + }, + { + UserId: 103, + TitleId: 2003, + Status: oapi.UserTitleStatusDropped, + Ctime: &time, + }, + } + + return oapi.GetUsersUserIdTitles200JSONResponse(userTitles), nil +} diff --git a/modules/server/helpers.go b/modules/backend/helpers.go similarity index 100% rename from modules/server/helpers.go rename to modules/backend/helpers.go diff --git a/modules/backend/main.go b/modules/backend/main.go new file mode 100644 index 0000000..42a66d3 --- /dev/null +++ b/modules/backend/main.go @@ -0,0 +1,119 @@ +package main + +import ( + "context" + "fmt" + sqlc "nyanimedb/sql" + "os" + "reflect" + "time" + + oapi "nyanimedb/api" + handlers "nyanimedb/modules/backend/handlers" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5" + "github.com/pelletier/go-toml/v2" +) + +var AppConfig Config + +func main() { + // if len(os.Args) != 2 { + // AppConfig.Mode = "env" + // } else { + // AppConfig.Mode = "argv" + // } + + // err := InitConfig() + // if err != nil { + // log.Fatalf("Failed to init config: %v\n", err) + // } + + conn, err := pgx.Connect(context.Background(), os.Getenv("DATABASE_URL")) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) + os.Exit(1) + } + + defer conn.Close(context.Background()) + + r := gin.Default() + + queries := sqlc.New(conn) + + server := handlers.NewServer(queries) + // r.LoadHTMLGlob("templates/*") + + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production + AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + })) + + oapi.RegisterHandlers(r, oapi.NewStrictHandler( + server, + // сюда можно добавить middlewares, если нужно + []oapi.StrictMiddlewareFunc{}, + )) + // r.GET("/", func(c *gin.Context) { + // c.HTML(http.StatusOK, "index.html", gin.H{ + // "title": "Welcome Page", + // "message": "Hello, Gin with HTML templates!", + // }) + // }) + + // r.GET("/api", func(c *gin.Context) { + // items := []Item{ + // {ID: 1, Title: "First Item", Description: "This is the description of the first item."}, + // {ID: 2, Title: "Second Item", Description: "This is the description of the second item."}, + // {ID: 3, Title: "Third Item", Description: "This is the description of the third item."}, + // } + + // c.JSON(http.StatusOK, items) + // }) + + r.Run(":8080") +} + +func InitConfig() error { + if AppConfig.Mode == "argv" { + content, err := os.ReadFile(os.Args[1]) + if err != nil { + return err + } + + toml.Unmarshal(content, &AppConfig) + + fmt.Printf("%+v\n", AppConfig) + + return nil + } else if AppConfig.Mode == "env" { + f := reflect.ValueOf(AppConfig) + + for i := 0; i < f.NumField(); i++ { + field := f.Type().Field(i) + tag := field.Tag + env_var := tag.Get("env") + fmt.Printf("Field: %v.\nEnvironment variable: %v.\n", field.Name, env_var) + if env_var != "" { + env_value, exists := os.LookupEnv(env_var) + if !exists { + return fmt.Errorf("there is no env variable %s", env_var) + } + err := setField(&AppConfig, field.Name, env_value) + if err != nil { + return fmt.Errorf("failed to set config field %s: %v", field.Name, err) + } + } + } + + return nil + } else { + return fmt.Errorf("incorrect config mode") + } +} diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql new file mode 100644 index 0000000..423be37 --- /dev/null +++ b/modules/backend/queries.sql @@ -0,0 +1,208 @@ +-- name: GetImageByID :one +SELECT id, storage_type, image_path +FROM images +WHERE id = sqlc.arg('illust_id')::bigint; + +-- name: CreateImage :one +INSERT INTO images (storage_type, image_path) +VALUES ($1, $2) +RETURNING id, storage_type, image_path; + +-- name: GetUserByID :one +SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date +FROM users +WHERE id = $1; + + +-- name: GetStudioByID :one +SELECT * +FROM studios +WHERE id = sqlc.arg('studio_id')::bigint; + +-- name: InsertStudio :one +INSERT INTO studios (studio_name, illust_id, studio_desc) +VALUES ( + sqlc.arg('studio_name')::text, + sqlc.narg('illust_id')::bigint, + sqlc.narg('studio_desc')::text) +RETURNING id, studio_name, illust_id, studio_desc; + +-- name: GetTitleTags :many +SELECT + tag_names +FROM tags as g +JOIN title_tags as t ON(t.tag_id = g.id) +WHERE t.title_id = sqlc.arg('title_id')::bigint; + +-- name: InsertTitleTags :one +INSERT INTO title_tags (title_id, tag_id) +VALUES ( + sqlc.arg('title_id')::bigint, + sqlc.arg('tag_id')::bigint) +RETURNING title_id, tag_id; + +-- name: InsertTag :one +INSERT INTO tags (tag_names) +VALUES ( + sqlc.arg('tag_names')::jsonb) +RETURNING id, tag_names; + +-- -- name: ListUsers :many +-- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date +-- FROM users +-- ORDER BY user_id +-- LIMIT $1 OFFSET $2; + +-- -- name: CreateUser :one +-- INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) +-- VALUES ($1, $2, $3, $4, $5, $6, $7) +-- RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; + +-- -- name: UpdateUser :one +-- UPDATE users +-- SET +-- avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), +-- disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), +-- user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), +-- passhash = COALESCE(sqlc.narg('passhash'), passhash) +-- WHERE user_id = sqlc.arg('user_id') +-- RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; + +-- -- name: DeleteUser :exec +-- DELETE FROM users +-- WHERE user_id = $1; + +-- name: GetTitleByID :one +SELECT * +FROM titles +WHERE id = sqlc.arg('title_id')::bigint; + +-- name: SearchTitles :many +SELECT + * +FROM titles +WHERE + CASE + WHEN sqlc.narg('word')::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + + AND (sqlc.narg('status')::title_status_t IS NULL OR title_status = sqlc.narg('status')::title_status_t) + AND (sqlc.narg('rating')::float IS NULL OR rating >= sqlc.narg('rating')::float) + AND (sqlc.narg('release_year')::int IS NULL OR release_year = sqlc.narg('release_year')::int) + AND (sqlc.narg('release_season')::release_season_t IS NULL OR release_season = sqlc.narg('release_season')::release_season_t) + +LIMIT COALESCE(sqlc.narg('limit')::int, 100) -- 100 is default limit +OFFSET sqlc.narg('offset')::int; + +-- -- name: ListTitles :many +-- SELECT title_id, title_names, studio_id, poster_id, signal_ids, +-- title_status, rating, rating_count, release_year, release_season, +-- season, episodes_aired, episodes_all, episodes_len +-- FROM titles +-- ORDER BY title_id +-- LIMIT $1 OFFSET $2; + +-- -- name: UpdateTitle :one +-- UPDATE titles +-- SET +-- title_names = COALESCE(sqlc.narg('title_names'), title_names), +-- studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), +-- poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), +-- signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), +-- title_status = COALESCE(sqlc.narg('title_status'), title_status), +-- release_year = COALESCE(sqlc.narg('release_year'), release_year), +-- release_season = COALESCE(sqlc.narg('release_season'), release_season), +-- episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), +-- episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), +-- episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) +-- WHERE title_id = sqlc.arg('title_id') +-- RETURNING *; + +-- name: GetReviewByID :one +SELECT * +FROM reviews +WHERE review_id = sqlc.arg('review_id')::bigint; + +-- -- name: CreateReview :one +-- INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date) +-- VALUES ($1, $2, $3, $4, $5) +-- RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date; + +-- -- name: UpdateReview :one +-- UPDATE reviews +-- SET +-- image_ids = COALESCE(sqlc.narg('image_ids'), image_ids), +-- review_text = COALESCE(sqlc.narg('review_text'), review_text) +-- WHERE review_id = sqlc.arg('review_id') +-- RETURNING *; + +-- -- name: DeleteReview :exec +-- DELETE FROM reviews +-- WHERE review_id = $1; + +-- name: ListReviewsByTitle :many +-- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +-- FROM reviews +-- WHERE title_id = $1 +-- ORDER BY creation_date DESC +-- LIMIT $2 OFFSET $3; + +-- -- name: ListReviewsByUser :many +-- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +-- FROM reviews +-- WHERE user_id = $1 +-- ORDER BY creation_date DESC +-- LIMIT $2 OFFSET $3; + +-- -- name: GetUserTitle :one +-- SELECT usertitle_id, user_id, title_id, status, rate, review_id +-- FROM usertitles +-- WHERE user_id = $1 AND title_id = $2; + +-- -- name: ListUserTitles :many +-- SELECT usertitle_id, user_id, title_id, status, rate, review_id +-- FROM usertitles +-- WHERE user_id = $1 +-- ORDER BY usertitle_id +-- LIMIT $2 OFFSET $3; + +-- -- name: CreateUserTitle :one +-- INSERT INTO usertitles (user_id, title_id, status, rate, review_id) +-- VALUES ($1, $2, $3, $4, $5) +-- RETURNING usertitle_id, user_id, title_id, status, rate, review_id; + +-- -- name: UpdateUserTitle :one +-- UPDATE usertitles +-- SET +-- status = COALESCE(sqlc.narg('status'), status), +-- rate = COALESCE(sqlc.narg('rate'), rate), +-- review_id = COALESCE(sqlc.narg('review_id'), review_id) +-- WHERE user_id = $1 AND title_id = $2 +-- RETURNING *; + +-- -- name: DeleteUserTitle :exec +-- DELETE FROM usertitles +-- WHERE user_id = $1 AND ($2::int IS NULL OR title_id = $2); + +-- -- name: ListTags :many +-- SELECT tag_id, tag_names +-- FROM tags +-- ORDER BY tag_id +-- LIMIT $1 OFFSET $2; \ No newline at end of file diff --git a/modules/backend/types.go b/modules/backend/types.go new file mode 100644 index 0000000..20d3158 --- /dev/null +++ b/modules/backend/types.go @@ -0,0 +1,12 @@ +package main + +type Config struct { + Mode string + LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` +} + +type Item struct { + ID int `json:"id"` + Title string `json:"title"` + Description string `json:"description"` +} diff --git a/modules/frontend/nginx-default.conf b/modules/frontend/nginx-default.conf new file mode 100644 index 0000000..a538968 --- /dev/null +++ b/modules/frontend/nginx-default.conf @@ -0,0 +1,29 @@ +server { + listen 80; + listen [::]:80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/v1/ { + rewrite ^/api/v1/(.*)$ /$1 break; + proxy_pass http://nyanimedb-backend:8080/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + #error_page 404 /404.html; + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + +} \ No newline at end of file diff --git a/modules/frontend/package-lock.json b/modules/frontend/package-lock.json index a282de5..f5dde46 100644 --- a/modules/frontend/package-lock.json +++ b/modules/frontend/package-lock.json @@ -8,9 +8,13 @@ "name": "nyanimedb-frontend", "version": "0.0.0", "dependencies": { + "@heroicons/react": "^2.2.0", + "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "react-router-dom": "^7.9.4", + "tailwindcss": "^4.1.17" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -22,11 +26,30 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", + "openapi-typescript-codegen": "^0.29.0", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -317,7 +340,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -334,7 +356,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -351,7 +372,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -368,7 +388,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -385,7 +404,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -402,7 +420,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -419,7 +436,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -436,7 +452,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -453,7 +468,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -470,7 +484,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -487,7 +500,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -504,7 +516,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -521,7 +532,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -538,7 +548,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -555,7 +564,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -572,7 +580,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -589,7 +596,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -606,7 +612,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -623,7 +628,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -640,7 +644,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -657,7 +660,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -674,7 +676,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -691,7 +692,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -708,7 +708,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -725,7 +724,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -742,7 +740,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -909,6 +906,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@heroicons/react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", + "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16 || ^19.0.0-rc" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -965,7 +971,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -976,7 +981,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -987,7 +991,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -997,20 +1000,25 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1063,7 +1071,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1077,7 +1084,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1091,7 +1097,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1105,7 +1110,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1119,7 +1123,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1133,7 +1136,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1147,7 +1149,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1161,7 +1162,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1175,7 +1175,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1189,7 +1188,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1203,7 +1201,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1217,7 +1214,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1231,7 +1227,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1245,7 +1240,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1259,7 +1253,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1273,7 +1266,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1287,7 +1279,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1301,7 +1292,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1315,7 +1305,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1329,7 +1318,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1343,7 +1331,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1357,13 +1344,269 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, + "node_modules/@tailwindcss/node": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", + "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", + "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-x64": "4.1.17", + "@tailwindcss/oxide-freebsd-x64": "4.1.17", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-x64-musl": "4.1.17", + "@tailwindcss/oxide-wasm32-wasi": "4.1.17", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", + "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", + "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", + "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", + "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", + "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", + "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", + "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", + "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", + "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", + "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.6.0", + "@emnapi/runtime": "^1.6.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", + "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", + "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz", + "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.17", + "@tailwindcss/oxide": "4.1.17", + "tailwindcss": "4.1.17" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1413,7 +1656,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -1427,7 +1669,7 @@ "version": "24.7.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.0.tgz", "integrity": "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -1928,6 +2170,19 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001749", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz", @@ -1998,6 +2253,16 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2012,6 +2277,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2068,6 +2342,15 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2089,6 +2372,19 @@ "dev": true, "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2138,7 +2434,6 @@ "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -2539,11 +2834,25 @@ "node": ">= 6" } }, + "node_modules/fs-extra": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", + "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2648,6 +2957,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -2655,6 +2970,28 @@ "dev": true, "license": "MIT" }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2781,6 +3118,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2848,6 +3194,19 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2872,6 +3231,255 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2905,6 +3513,15 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2972,6 +3589,16 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2983,7 +3610,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -3005,6 +3631,13 @@ "dev": true, "license": "MIT" }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.23", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", @@ -3012,6 +3645,23 @@ "dev": true, "license": "MIT" }, + "node_modules/openapi-typescript-codegen": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-codegen/-/openapi-typescript-codegen-0.29.0.tgz", + "integrity": "sha512-/wC42PkD0LGjDTEULa/XiWQbv4E9NwLjwLjsaJ/62yOsoYhwvmBR31kPttn1DzQ2OlGe5stACcF/EIkZk43M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.5.4", + "camelcase": "^6.3.0", + "commander": "^12.0.0", + "fs-extra": "^11.2.0", + "handlebars": "^4.7.8" + }, + "bin": { + "openapi": "bin/index.js" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3099,7 +3749,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -3119,7 +3768,6 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -3206,6 +3854,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3223,6 +3872,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz", + "integrity": "sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.4.tgz", + "integrity": "sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA==", + "license": "MIT", + "dependencies": { + "react-router": "7.9.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3248,7 +3935,6 @@ "version": "4.52.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -3326,6 +4012,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3349,11 +4041,20 @@ "node": ">=8" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -3385,11 +4086,29 @@ "node": ">=8" } }, + "node_modules/tailwindcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -3406,7 +4125,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -3424,7 +4142,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "peer": true, "engines": { @@ -3512,13 +4229,37 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undici-types": { "version": "7.14.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -3564,7 +4305,6 @@ "version": "7.1.9", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -3640,7 +4380,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -3658,7 +4397,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "peer": true, "engines": { @@ -3694,6 +4432,13 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/modules/frontend/package.json b/modules/frontend/package.json index 9fbc7aa..beb2b2a 100644 --- a/modules/frontend/package.json +++ b/modules/frontend/package.json @@ -10,9 +10,13 @@ "preview": "vite preview" }, "dependencies": { + "@heroicons/react": "^2.2.0", + "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "react-router-dom": "^7.9.4", + "tailwindcss": "^4.1.17" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -24,6 +28,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", + "openapi-typescript-codegen": "^0.29.0", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index d9e6fbd..f67c37e 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -1,38 +1,17 @@ -import React, { useEffect, useState } from "react"; -import { fetchItems } from "./services/api"; -import type { Item } from "./services/api"; -import ItemTemplate from "./components/ItemTemplate"; +import React from "react"; +import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; +import UserPage from "./pages/UserPage/UserPage"; +import TitlesPage from "./pages/TitlesPage/TitlesPage"; const App: React.FC = () => { - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const getData = async () => { - try { - const data = await fetchItems(); - setItems(data); - } catch (err) { - setError("Failed to fetch items."); - } finally { - setLoading(false); - } - }; - getData(); - }, []); - - if (loading) return
Loading...
; - if (error) return
{error}
; - return ( -
-

Items List

- {items.map((item) => ( - - ))} -
+ + + } /> + } /> + + ); }; -export default App; +export default App; \ No newline at end of file diff --git a/modules/frontend/src/api/core/ApiError.ts b/modules/frontend/src/api/core/ApiError.ts new file mode 100644 index 0000000..ec7b16a --- /dev/null +++ b/modules/frontend/src/api/core/ApiError.ts @@ -0,0 +1,25 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; + +export class ApiError extends Error { + public readonly url: string; + public readonly status: number; + public readonly statusText: string; + public readonly body: any; + public readonly request: ApiRequestOptions; + + constructor(request: ApiRequestOptions, response: ApiResult, message: string) { + super(message); + + this.name = 'ApiError'; + this.url = response.url; + this.status = response.status; + this.statusText = response.statusText; + this.body = response.body; + this.request = request; + } +} diff --git a/modules/frontend/src/api/core/ApiRequestOptions.ts b/modules/frontend/src/api/core/ApiRequestOptions.ts new file mode 100644 index 0000000..93143c3 --- /dev/null +++ b/modules/frontend/src/api/core/ApiRequestOptions.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiRequestOptions = { + readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; + readonly url: string; + readonly path?: Record; + readonly cookies?: Record; + readonly headers?: Record; + readonly query?: Record; + readonly formData?: Record; + readonly body?: any; + readonly mediaType?: string; + readonly responseHeader?: string; + readonly errors?: Record; +}; diff --git a/modules/frontend/src/api/core/ApiResult.ts b/modules/frontend/src/api/core/ApiResult.ts new file mode 100644 index 0000000..ee1126e --- /dev/null +++ b/modules/frontend/src/api/core/ApiResult.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiResult = { + readonly url: string; + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly body: any; +}; diff --git a/modules/frontend/src/api/core/CancelablePromise.ts b/modules/frontend/src/api/core/CancelablePromise.ts new file mode 100644 index 0000000..d70de92 --- /dev/null +++ b/modules/frontend/src/api/core/CancelablePromise.ts @@ -0,0 +1,131 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export class CancelError extends Error { + + constructor(message: string) { + super(message); + this.name = 'CancelError'; + } + + public get isCancelled(): boolean { + return true; + } +} + +export interface OnCancel { + readonly isResolved: boolean; + readonly isRejected: boolean; + readonly isCancelled: boolean; + + (cancelHandler: () => void): void; +} + +export class CancelablePromise implements Promise { + #isResolved: boolean; + #isRejected: boolean; + #isCancelled: boolean; + readonly #cancelHandlers: (() => void)[]; + readonly #promise: Promise; + #resolve?: (value: T | PromiseLike) => void; + #reject?: (reason?: any) => void; + + constructor( + executor: ( + resolve: (value: T | PromiseLike) => void, + reject: (reason?: any) => void, + onCancel: OnCancel + ) => void + ) { + this.#isResolved = false; + this.#isRejected = false; + this.#isCancelled = false; + this.#cancelHandlers = []; + this.#promise = new Promise((resolve, reject) => { + this.#resolve = resolve; + this.#reject = reject; + + const onResolve = (value: T | PromiseLike): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isResolved = true; + if (this.#resolve) this.#resolve(value); + }; + + const onReject = (reason?: any): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isRejected = true; + if (this.#reject) this.#reject(reason); + }; + + const onCancel = (cancelHandler: () => void): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#cancelHandlers.push(cancelHandler); + }; + + Object.defineProperty(onCancel, 'isResolved', { + get: (): boolean => this.#isResolved, + }); + + Object.defineProperty(onCancel, 'isRejected', { + get: (): boolean => this.#isRejected, + }); + + Object.defineProperty(onCancel, 'isCancelled', { + get: (): boolean => this.#isCancelled, + }); + + return executor(onResolve, onReject, onCancel as OnCancel); + }); + } + + get [Symbol.toStringTag]() { + return "Cancellable Promise"; + } + + public then( + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onRejected?: ((reason: any) => TResult2 | PromiseLike) | null + ): Promise { + return this.#promise.then(onFulfilled, onRejected); + } + + public catch( + onRejected?: ((reason: any) => TResult | PromiseLike) | null + ): Promise { + return this.#promise.catch(onRejected); + } + + public finally(onFinally?: (() => void) | null): Promise { + return this.#promise.finally(onFinally); + } + + public cancel(): void { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isCancelled = true; + if (this.#cancelHandlers.length) { + try { + for (const cancelHandler of this.#cancelHandlers) { + cancelHandler(); + } + } catch (error) { + console.warn('Cancellation threw an error', error); + return; + } + } + this.#cancelHandlers.length = 0; + if (this.#reject) this.#reject(new CancelError('Request aborted')); + } + + public get isCancelled(): boolean { + return this.#isCancelled; + } +} diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts new file mode 100644 index 0000000..185e5c3 --- /dev/null +++ b/modules/frontend/src/api/core/OpenAPI.ts @@ -0,0 +1,32 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; + +type Resolver = (options: ApiRequestOptions) => Promise; +type Headers = Record; + +export type OpenAPIConfig = { + BASE: string; + VERSION: string; + WITH_CREDENTIALS: boolean; + CREDENTIALS: 'include' | 'omit' | 'same-origin'; + TOKEN?: string | Resolver | undefined; + USERNAME?: string | Resolver | undefined; + PASSWORD?: string | Resolver | undefined; + HEADERS?: Headers | Resolver | undefined; + ENCODE_PATH?: ((path: string) => string) | undefined; +}; + +export const OpenAPI: OpenAPIConfig = { + BASE: '/api/v1', + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'include', + TOKEN: undefined, + USERNAME: undefined, + PASSWORD: undefined, + HEADERS: undefined, + ENCODE_PATH: undefined, +}; diff --git a/modules/frontend/src/api/core/request.ts b/modules/frontend/src/api/core/request.ts new file mode 100644 index 0000000..1dc6fef --- /dev/null +++ b/modules/frontend/src/api/core/request.ts @@ -0,0 +1,323 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import axios from 'axios'; +import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; +import FormData from 'form-data'; + +import { ApiError } from './ApiError'; +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; +import { CancelablePromise } from './CancelablePromise'; +import type { OnCancel } from './CancelablePromise'; +import type { OpenAPIConfig } from './OpenAPI'; + +export const isDefined = (value: T | null | undefined): value is Exclude => { + return value !== undefined && value !== null; +}; + +export const isString = (value: any): value is string => { + return typeof value === 'string'; +}; + +export const isStringWithValue = (value: any): value is string => { + return isString(value) && value !== ''; +}; + +export const isBlob = (value: any): value is Blob => { + return ( + typeof value === 'object' && + typeof value.type === 'string' && + typeof value.stream === 'function' && + typeof value.arrayBuffer === 'function' && + typeof value.constructor === 'function' && + typeof value.constructor.name === 'string' && + /^(Blob|File)$/.test(value.constructor.name) && + /^(Blob|File)$/.test(value[Symbol.toStringTag]) + ); +}; + +export const isFormData = (value: any): value is FormData => { + return value instanceof FormData; +}; + +export const isSuccess = (status: number): boolean => { + return status >= 200 && status < 300; +}; + +export const base64 = (str: string): string => { + try { + return btoa(str); + } catch (err) { + // @ts-ignore + return Buffer.from(str).toString('base64'); + } +}; + +export const getQueryString = (params: Record): string => { + const qs: string[] = []; + + const append = (key: string, value: any) => { + qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; + + const process = (key: string, value: any) => { + if (isDefined(value)) { + if (Array.isArray(value)) { + value.forEach(v => { + process(key, v); + }); + } else if (typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => { + process(`${key}[${k}]`, v); + }); + } else { + append(key, value); + } + } + }; + + Object.entries(params).forEach(([key, value]) => { + process(key, value); + }); + + if (qs.length > 0) { + return `?${qs.join('&')}`; + } + + return ''; +}; + +const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { + const encoder = config.ENCODE_PATH || encodeURI; + + const path = options.url + .replace('{api-version}', config.VERSION) + .replace(/{(.*?)}/g, (substring: string, group: string) => { + if (options.path?.hasOwnProperty(group)) { + return encoder(String(options.path[group])); + } + return substring; + }); + + const url = `${config.BASE}${path}`; + if (options.query) { + return `${url}${getQueryString(options.query)}`; + } + return url; +}; + +export const getFormData = (options: ApiRequestOptions): FormData | undefined => { + if (options.formData) { + const formData = new FormData(); + + const process = (key: string, value: any) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + }; + + Object.entries(options.formData) + .filter(([_, value]) => isDefined(value)) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => process(key, v)); + } else { + process(key, value); + } + }); + + return formData; + } + return undefined; +}; + +type Resolver = (options: ApiRequestOptions) => Promise; + +export const resolve = async (options: ApiRequestOptions, resolver?: T | Resolver): Promise => { + if (typeof resolver === 'function') { + return (resolver as Resolver)(options); + } + return resolver; +}; + +export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise> => { + const [token, username, password, additionalHeaders] = await Promise.all([ + resolve(options, config.TOKEN), + resolve(options, config.USERNAME), + resolve(options, config.PASSWORD), + resolve(options, config.HEADERS), + ]); + + const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {} + + const headers = Object.entries({ + Accept: 'application/json', + ...additionalHeaders, + ...options.headers, + ...formHeaders, + }) + .filter(([_, value]) => isDefined(value)) + .reduce((headers, [key, value]) => ({ + ...headers, + [key]: String(value), + }), {} as Record); + + if (isStringWithValue(token)) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers['Authorization'] = `Basic ${credentials}`; + } + + if (options.body !== undefined) { + if (options.mediaType) { + headers['Content-Type'] = options.mediaType; + } else if (isBlob(options.body)) { + headers['Content-Type'] = options.body.type || 'application/octet-stream'; + } else if (isString(options.body)) { + headers['Content-Type'] = 'text/plain'; + } else if (!isFormData(options.body)) { + headers['Content-Type'] = 'application/json'; + } + } + + return headers; +}; + +export const getRequestBody = (options: ApiRequestOptions): any => { + if (options.body) { + return options.body; + } + return undefined; +}; + +export const sendRequest = async ( + config: OpenAPIConfig, + options: ApiRequestOptions, + url: string, + body: any, + formData: FormData | undefined, + headers: Record, + onCancel: OnCancel, + axiosClient: AxiosInstance +): Promise> => { + const source = axios.CancelToken.source(); + + const requestConfig: AxiosRequestConfig = { + url, + headers, + data: body ?? formData, + method: options.method, + withCredentials: config.WITH_CREDENTIALS, + withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false, + cancelToken: source.token, + }; + + onCancel(() => source.cancel('The user aborted a request.')); + + try { + return await axiosClient.request(requestConfig); + } catch (error) { + const axiosError = error as AxiosError; + if (axiosError.response) { + return axiosError.response; + } + throw error; + } +}; + +export const getResponseHeader = (response: AxiosResponse, responseHeader?: string): string | undefined => { + if (responseHeader) { + const content = response.headers[responseHeader]; + if (isString(content)) { + return content; + } + } + return undefined; +}; + +export const getResponseBody = (response: AxiosResponse): any => { + if (response.status !== 204) { + return response.data; + } + return undefined; +}; + +export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { + const errors: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + ...options.errors, + } + + const error = errors[result.status]; + if (error) { + throw new ApiError(options, result, error); + } + + if (!result.ok) { + const errorStatus = result.status ?? 'unknown'; + const errorStatusText = result.statusText ?? 'unknown'; + const errorBody = (() => { + try { + return JSON.stringify(result.body, null, 2); + } catch (e) { + return undefined; + } + })(); + + throw new ApiError(options, result, + `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` + ); + } +}; + +/** + * Request method + * @param config The OpenAPI configuration object + * @param options The request options from the service + * @param axiosClient The axios client instance to use + * @returns CancelablePromise + * @throws ApiError + */ +export const request = (config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + try { + const url = getUrl(config, options); + const formData = getFormData(options); + const body = getRequestBody(options); + const headers = await getHeaders(config, options, formData); + + if (!onCancel.isCancelled) { + const response = await sendRequest(config, options, url, body, formData, headers, onCancel, axiosClient); + const responseBody = getResponseBody(response); + const responseHeader = getResponseHeader(response, options.responseHeader); + + const result: ApiResult = { + url, + ok: isSuccess(response.status), + status: response.status, + statusText: response.statusText, + body: responseHeader ?? responseBody, + }; + + catchErrorCodes(options, result); + + resolve(result.body); + } + } catch (error) { + reject(error); + } + }); +}; diff --git a/modules/frontend/src/api/index.ts b/modules/frontend/src/api/index.ts new file mode 100644 index 0000000..80ae491 --- /dev/null +++ b/modules/frontend/src/api/index.ts @@ -0,0 +1,26 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export { ApiError } from './core/ApiError'; +export { CancelablePromise, CancelError } from './core/CancelablePromise'; +export { OpenAPI } from './core/OpenAPI'; +export type { OpenAPIConfig } from './core/OpenAPI'; + +export type { cursor } from './models/cursor'; +export type { CursorObj } from './models/CursorObj'; +export type { Image } from './models/Image'; +export type { ReleaseSeason } from './models/ReleaseSeason'; +export type { Review } from './models/Review'; +export type { Studio } from './models/Studio'; +export type { Tag } from './models/Tag'; +export type { Tags } from './models/Tags'; +export type { Title } from './models/Title'; +export type { title_sort } from './models/title_sort'; +export type { TitleSort } from './models/TitleSort'; +export type { TitleStatus } from './models/TitleStatus'; +export type { User } from './models/User'; +export type { UserTitle } from './models/UserTitle'; +export type { UserTitleStatus } from './models/UserTitleStatus'; + +export { DefaultService } from './services/DefaultService'; diff --git a/modules/frontend/src/api/models/CursorObj.ts b/modules/frontend/src/api/models/CursorObj.ts new file mode 100644 index 0000000..f54abb1 --- /dev/null +++ b/modules/frontend/src/api/models/CursorObj.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type CursorObj = { + id: number; + param?: string; +}; + diff --git a/modules/frontend/src/api/models/Image.ts b/modules/frontend/src/api/models/Image.ts new file mode 100644 index 0000000..1317db7 --- /dev/null +++ b/modules/frontend/src/api/models/Image.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Image = { + id?: number; + storage_type?: string; + image_path?: string; +}; + diff --git a/modules/frontend/src/api/models/ReleaseSeason.ts b/modules/frontend/src/api/models/ReleaseSeason.ts new file mode 100644 index 0000000..ad9f930 --- /dev/null +++ b/modules/frontend/src/api/models/ReleaseSeason.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Title release season + */ +export type ReleaseSeason = 'winter' | 'spring' | 'summer' | 'fall'; diff --git a/modules/frontend/src/api/models/Review.ts b/modules/frontend/src/api/models/Review.ts new file mode 100644 index 0000000..9b453b7 --- /dev/null +++ b/modules/frontend/src/api/models/Review.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Review = Record; diff --git a/modules/frontend/src/api/models/Studio.ts b/modules/frontend/src/api/models/Studio.ts new file mode 100644 index 0000000..062695a --- /dev/null +++ b/modules/frontend/src/api/models/Studio.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Image } from './Image'; +export type Studio = { + id: number; + name: string; + poster?: Image; + description?: string; +}; + diff --git a/modules/frontend/src/api/models/Tag.ts b/modules/frontend/src/api/models/Tag.ts new file mode 100644 index 0000000..665c724 --- /dev/null +++ b/modules/frontend/src/api/models/Tag.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * A localized tag: keys are language codes (ISO 639-1), values are tag names + */ +export type Tag = Record; diff --git a/modules/frontend/src/api/models/Tags.ts b/modules/frontend/src/api/models/Tags.ts new file mode 100644 index 0000000..748f066 --- /dev/null +++ b/modules/frontend/src/api/models/Tags.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Tag } from './Tag'; +/** + * Array of localized tags + */ +export type Tags = Array; diff --git a/modules/frontend/src/api/models/Title.ts b/modules/frontend/src/api/models/Title.ts new file mode 100644 index 0000000..4da7aa3 --- /dev/null +++ b/modules/frontend/src/api/models/Title.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Title = Record; diff --git a/modules/frontend/src/api/models/TitleSort.ts b/modules/frontend/src/api/models/TitleSort.ts new file mode 100644 index 0000000..1c9385e --- /dev/null +++ b/modules/frontend/src/api/models/TitleSort.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Title sort order + */ +export type TitleSort = 'id' | 'year' | 'rating' | 'views'; diff --git a/modules/frontend/src/api/models/TitleStatus.ts b/modules/frontend/src/api/models/TitleStatus.ts new file mode 100644 index 0000000..72e0261 --- /dev/null +++ b/modules/frontend/src/api/models/TitleStatus.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Title status + */ +export type TitleStatus = 'finished' | 'ongoing' | 'planned'; diff --git a/modules/frontend/src/api/models/User.ts b/modules/frontend/src/api/models/User.ts new file mode 100644 index 0000000..cd76dbe --- /dev/null +++ b/modules/frontend/src/api/models/User.ts @@ -0,0 +1,35 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type User = { + /** + * Unique user ID (primary key) + */ + id?: number; + /** + * ID of the user avatar (references images table) + */ + avatar_id?: number; + /** + * User email + */ + mail?: string; + /** + * Username (alphanumeric + _ or -) + */ + nickname: string; + /** + * Display name + */ + disp_name?: string; + /** + * User description + */ + user_desc?: string; + /** + * Timestamp when the user was created + */ + creation_date?: string; +}; + diff --git a/modules/frontend/src/api/models/UserTitle.ts b/modules/frontend/src/api/models/UserTitle.ts new file mode 100644 index 0000000..26d5ddc --- /dev/null +++ b/modules/frontend/src/api/models/UserTitle.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type UserTitle = Record; diff --git a/modules/frontend/src/api/models/UserTitleStatus.ts b/modules/frontend/src/api/models/UserTitleStatus.ts new file mode 100644 index 0000000..0a29626 --- /dev/null +++ b/modules/frontend/src/api/models/UserTitleStatus.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * User's title status + */ +export type UserTitleStatus = 'finished' | 'planned' | 'dropped' | 'in-progress'; diff --git a/modules/frontend/src/api/models/cursor.ts b/modules/frontend/src/api/models/cursor.ts new file mode 100644 index 0000000..5788e14 --- /dev/null +++ b/modules/frontend/src/api/models/cursor.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type cursor = string; diff --git a/modules/frontend/src/api/models/title_sort.ts b/modules/frontend/src/api/models/title_sort.ts new file mode 100644 index 0000000..69b01a7 --- /dev/null +++ b/modules/frontend/src/api/models/title_sort.ts @@ -0,0 +1,6 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { TitleSort } from './TitleSort'; +export type title_sort = TitleSort; diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts new file mode 100644 index 0000000..52321b8 --- /dev/null +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -0,0 +1,178 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CursorObj } from '../models/CursorObj'; +import type { ReleaseSeason } from '../models/ReleaseSeason'; +import type { Title } from '../models/Title'; +import type { TitleSort } from '../models/TitleSort'; +import type { TitleStatus } from '../models/TitleStatus'; +import type { User } from '../models/User'; +import type { UserTitle } from '../models/UserTitle'; +import type { UserTitleStatus } from '../models/UserTitleStatus'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class DefaultService { + /** + * Get titles + * @param cursor + * @param sort + * @param sortForward + * @param word + * @param status + * @param rating + * @param releaseYear + * @param releaseSeason + * @param limit + * @param offset + * @param fields + * @returns any List of titles with cursor + * @throws ApiError + */ + public static getTitles( + cursor?: string, + sort?: TitleSort, + sortForward: boolean = true, + word?: string, + status?: TitleStatus, + rating?: number, + releaseYear?: number, + releaseSeason?: ReleaseSeason, + limit: number = 10, + offset?: number, + fields: string = 'all', + ): CancelablePromise<{ + /** + * List of titles + */ + data: Array; + cursor: CursorObj; + }> { + return __request(OpenAPI, { + method: 'GET', + url: '/titles', + query: { + 'cursor': cursor, + 'sort': sort, + 'sort_forward': sortForward, + 'word': word, + 'status': status, + 'rating': rating, + 'release_year': releaseYear, + 'release_season': releaseSeason, + 'limit': limit, + 'offset': offset, + 'fields': fields, + }, + errors: { + 400: `Request params are not correct`, + 500: `Unknown server error`, + }, + }); + } + /** + * Get title description + * @param titleId + * @param fields + * @returns Title Title description + * @throws ApiError + */ + public static getTitles1( + titleId: number, + fields: string = 'all', + ): CancelablePromise<Title> { + return __request(OpenAPI, { + method: 'GET', + url: '/titles/{title_id}', + path: { + 'title_id': titleId, + }, + query: { + 'fields': fields, + }, + errors: { + 400: `Request params are not correct`, + 404: `Title not found`, + 500: `Unknown server error`, + }, + }); + } + /** + * Get user info + * @param userId + * @param fields + * @returns User User info + * @throws ApiError + */ + public static getUsers( + userId: string, + fields: string = 'all', + ): CancelablePromise<User> { + return __request(OpenAPI, { + method: 'GET', + url: '/users/{user_id}', + path: { + 'user_id': userId, + }, + query: { + 'fields': fields, + }, + errors: { + 400: `Request params are not correct`, + 404: `User not found`, + 500: `Unknown server error`, + }, + }); + } + /** + * Get user titles + * @param userId + * @param cursor + * @param word + * @param status + * @param watchStatus + * @param rating + * @param releaseYear + * @param releaseSeason + * @param limit + * @param fields + * @returns UserTitle List of user titles + * @throws ApiError + */ + public static getUsersTitles( + userId: string, + cursor?: string, + word?: string, + status?: TitleStatus, + watchStatus?: UserTitleStatus, + rating?: number, + releaseYear?: number, + releaseSeason?: ReleaseSeason, + limit: number = 10, + fields: string = 'all', + ): CancelablePromise<Array<UserTitle>> { + return __request(OpenAPI, { + method: 'GET', + url: '/users/{user_id}/titles/', + path: { + 'user_id': userId, + }, + query: { + 'cursor': cursor, + 'word': word, + 'status': status, + 'watch_status': watchStatus, + 'rating': rating, + 'release_year': releaseYear, + 'release_season': releaseSeason, + 'limit': limit, + 'fields': fields, + }, + errors: { + 400: `Request params are not correct`, + 500: `Unknown server error`, + }, + }); + } +} diff --git a/modules/frontend/src/components/ListView/ListView.tsx b/modules/frontend/src/components/ListView/ListView.tsx new file mode 100644 index 0000000..e0e8ab9 --- /dev/null +++ b/modules/frontend/src/components/ListView/ListView.tsx @@ -0,0 +1,103 @@ +import React, { useState, useEffect } from "react"; +import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid"; +import type { CursorObj } from "../../api"; + +export type ListViewProps<T> = { + fetchItems: (cursor: string, limit: number) => Promise<{ items: T[]; cursor: CursorObj}>; + renderItem: (item: T, layout: "square" | "horizontal") => React.ReactNode; + pageSize?: number; + searchPlaceholder?: string; + setSearch: any; +}; + +export function ListView<T>({ + fetchItems, + renderItem, + pageSize = 20, + searchPlaceholder = "Search...", +}: ListViewProps<T>) { + const [items, setItems] = useState<T[]>([]); + const [cursorObj, setCursorObj] = useState<CursorObj | undefined>(undefined); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [search, setSearch] = useState(""); + const [layout, setLayout] = useState<"square" | "horizontal">("horizontal"); + const [error, setError] = useState<string | null>(null); + + const loadItems = async (reset: boolean = false) => { + try { + if (reset) { + setLoading(true); + setCursorObj(undefined); + } else { + setLoadingMore(true); + } + + const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)) : "" + console.log("encoded cursor: " + cursorStr) + + const result = await fetchItems(cursorStr, pageSize); + + if (reset) setItems(result.items); + else setItems(prev => [...prev, ...result.items]); + + setCursorObj(result.cursor); + setError(null); + } catch (err: any) { + console.error(err); + setError("Failed to fetch items."); + } finally { + setLoading(false); + setLoadingMore(false); + } + }; + + useEffect(() => { + loadItems(true); + }, [search]); + + return ( + <div className="w-full min-h-screen bg-gray-50 p-6 text-black flex flex-col items-center"> + <div className="w-full sm:w-4/5 flex gap-4 mb-8"> + <input + type="text" + placeholder={searchPlaceholder} + // value={search} + onChange={e => setSearch(e.target.value)} + className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-black" + /> + <button + className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition" + onClick={() => + setLayout(prev => (prev === "square" ? "horizontal" : "square")) + }> + {layout === "square" ? <Squares2X2Icon className="w-6 h-6" /> : <Bars3Icon className="w-6 h-6" />} + </button> + </div> + + {error && <div className="text-red-600 mb-6 font-medium">{error}</div>} + + <div + className={`w-full sm:w-4/5 grid gap-6 ${ + layout === "square" ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" : "grid-cols-1" + }`} + > + {items.map(item => renderItem(item, layout))} + </div> + + {cursorObj && ( + <div className="mt-8 flex justify-center w-full sm:w-4/5"> + <button + className="px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed" + onClick={() => loadItems(false)} + disabled={loadingMore} + > + {loadingMore ? "Loading..." : "Load More"} + </button> + </div> + )} + + {loading && <div className="mt-20 font-medium">Loading...</div>} + </div> + ); +} diff --git a/modules/frontend/src/components/cards/TitleCardHorizontal.tsx b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx new file mode 100644 index 0000000..cde6037 --- /dev/null +++ b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx @@ -0,0 +1,22 @@ +import type { Title } from "../../api/models/Title"; + +export function TitleCardHorizontal({ title }: { title: Title }) { + return ( + <div style={{ + display: "flex", + gap: 12, + padding: 12, + border: "1px solid #ddd", + borderRadius: 8 + }}> + {title.poster?.image_path && ( + <img src={title.poster.image_path} width={80} /> + )} + <div> + <h3>{title.title_names["en"]}</h3> + <p>{title.release_year} · {title.release_season} · Rating: {title.rating}</p> + <p>Status: {title.title_status}</p> + </div> + </div> + ); +} diff --git a/modules/frontend/src/components/cards/TitleCardSquare.tsx b/modules/frontend/src/components/cards/TitleCardSquare.tsx new file mode 100644 index 0000000..e21c258 --- /dev/null +++ b/modules/frontend/src/components/cards/TitleCardSquare.tsx @@ -0,0 +1,22 @@ +// TitleCardSquare.tsx +import type { Title } from "../../api/models/Title"; + +export function TitleCardSquare({ title }: { title: Title }) { + return ( + <div style={{ + width: 160, + border: "1px solid #ddd", + padding: 8, + borderRadius: 8, + textAlign: "center" + }}> + {title.poster?.image_path && ( + <img src={title.poster.image_path} width={140} /> + )} + <div> + <h4>{title.title_names["en"]}</h4> + <small>{title.release_year} • {title.rating}</small> + </div> + </div> + ); +} diff --git a/modules/frontend/src/index.css b/modules/frontend/src/index.css index 08a3ac9..e20de02 100644 --- a/modules/frontend/src/index.css +++ b/modules/frontend/src/index.css @@ -1,68 +1,8 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; +@import "tailwindcss"; - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { +html, body, #root { margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} + padding: 0; + width: 100%; + height: 100%; +} \ No newline at end of file diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.module.css b/modules/frontend/src/pages/TitlePage/TitlePage.module.css new file mode 100644 index 0000000..e69de29 diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.tsx b/modules/frontend/src/pages/TitlePage/TitlePage.tsx new file mode 100644 index 0000000..7fe9de7 --- /dev/null +++ b/modules/frontend/src/pages/TitlePage/TitlePage.tsx @@ -0,0 +1,64 @@ +// import React, { useEffect, useState } from "react"; +// import { useParams } from "react-router-dom"; +// import { DefaultService } from "../../api/services/DefaultService"; +// import type { User } from "../../api/models/User"; +// import styles from "./UserPage.module.css"; + +// const UserPage: React.FC = () => { +// const { id } = useParams<{ id: string }>(); +// const [user, setUser] = useState<User | null>(null); +// const [loading, setLoading] = useState(true); +// const [error, setError] = useState<string | null>(null); + +// useEffect(() => { +// if (!id) return; + +// const getTitleInfo = async () => { +// try { +// const userInfo = await DefaultService.getTitle(id, "all"); +// setUser(userInfo); +// } catch (err) { +// console.error(err); +// setError("Failed to fetch user info."); +// } finally { +// setLoading(false); +// } +// }; +// getTitleInfo(); +// }, [id]); + +// if (loading) return <div className={styles.loader}>Loading...</div>; +// if (error) return <div className={styles.error}>{error}</div>; +// if (!user) return <div className={styles.error}>User not found.</div>; + +// return ( +// <div className={styles.container}> +// <div className={styles.card}> +// <div className={styles.avatar}> +// {user.avatar_id ? ( +// <img +// src={`/images/${user.avatar_id}.png`} +// alt="User Avatar" +// className={styles.avatarImg} +// /> +// ) : ( +// <div className={styles.avatarPlaceholder}> +// {user.disp_name?.[0] || "U"} +// </div> +// )} +// </div> + +// <div className={styles.info}> +// <h1 className={styles.name}>{user.disp_name || user.nickname}</h1> +// <p className={styles.nickname}>@{user.nickname}</p> +// {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>} +// <p className={styles.created}> +// Joined: {new Date(user.creation_date).toLocaleDateString()} +// </p> +// </div> +// </div> +// </div> +// ); +// }; + +// export default UserPage; diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css b/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx new file mode 100644 index 0000000..b59f737 --- /dev/null +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -0,0 +1,52 @@ +import { ListView } from "../../components/ListView/ListView"; +import { DefaultService } from "../../api/services/DefaultService"; +import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; +import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; +import type { Title } from "../../api"; +import { useState, useEffect } from "react"; + +const PAGE_SIZE = 20; +export default function TitlesPage() { + const [search, setSearch] = useState(""); + + const loadTitles = async (cursor: string, limit: number) => { + const result = await DefaultService.getTitles( + cursor, + undefined, + true, + search, + undefined, + undefined, + undefined, + undefined, + limit, + undefined, + 'all' + ); + + return { + items: result.data ?? [], + cursor: result.cursor ?? null, + }; + }; + + return ( + <div className="w-full min-h-screen bg-gray-50 p-6 text-black flex flex-col items-center"> + + <h1 className="text-4xl font-bold mb-6 text-center">Titles</h1> + + <ListView<Title> + pageSize={PAGE_SIZE} + fetchItems={loadTitles} + searchPlaceholder="Search titles..." + renderItem={(title, layout) => + layout === "square" + ? <TitleCardSquare title={title} /> + : <TitleCardHorizontal title={title} /> + } + setSearch={setSearch} + /> + </div> + ); +} + diff --git a/modules/frontend/src/pages/UserPage/UserPage.module.css b/modules/frontend/src/pages/UserPage/UserPage.module.css new file mode 100644 index 0000000..7f350c8 --- /dev/null +++ b/modules/frontend/src/pages/UserPage/UserPage.module.css @@ -0,0 +1,103 @@ +body, +html { + width: 100%; + margin: 0; + background-color: #777; + color: #fff; +} + +html, +body, +#root { + height: 100%; +} + +.header { + width: 100vw; + padding: 30px 40px; + background: #f7f7f7; + display: flex; + align-items: center; + gap: 25px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + border-bottom: 1px solid #e5e5e5; + color: #000000; +} + +.avatarWrapper { + width: 120px; + height: 120px; + min-width: 120px; + border-radius: 50%; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: #ddd; +} + +.avatarImg { + width: 100%; + height: 100%; + object-fit: cover; +} + +.avatarPlaceholder { + width: 100%; + height: 100%; + border-radius: 50%; + background: #ccc; + font-size: 42px; + font-weight: bold; + color: #555; + display: flex; + align-items: center; + justify-content: center; +} + +.userInfo { + display: flex; + flex-direction: column; +} + +.name { + font-size: 32px; + font-weight: 700; + margin: 0; +} + +.nickname { + font-size: 18px; + color: #666; + margin-top: 6px; +} + +.container { + max-width: 100vw; + width: 100%; + position: absolute; + top: 0%; + /* margin: 25px auto; */ + /* padding: 0 20px; */ +} + +.content { + margin-top: 20px; +} + +.desc { + font-size: 18px; + margin-bottom: 10px; +} + +.created { + font-size: 16px; + color: #888; +} + +.loader, +.error { + text-align: center; + margin-top: 40px; + font-size: 18px; +} diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx new file mode 100644 index 0000000..52c5574 --- /dev/null +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -0,0 +1,67 @@ +import React, { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; // <-- import +import { DefaultService } from "../../api/services/DefaultService"; +import type { User } from "../../api/models/User"; +import styles from "./UserPage.module.css"; + +const UserPage: React.FC = () => { + const { id } = useParams<{ id: string }>(); // <-- get user id from URL + const [user, setUser] = useState<User | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + if (!id) return; + + const getUserInfo = async () => { + try { + const userInfo = await DefaultService.getUsers(id, "all"); // <-- use dynamic id + setUser(userInfo); + } catch (err) { + console.error(err); + setError("Failed to fetch user info."); + } finally { + setLoading(false); + } + }; + getUserInfo(); + }, [id]); + + if (loading) return <div className={styles.loader}>Loading...</div>; + if (error) return <div className={styles.error}>{error}</div>; + if (!user) return <div className={styles.error}>User not found.</div>; + + return ( + <div className={styles.container}> + <div className={styles.header}> + <div className={styles.avatarWrapper}> + {user.avatar_id ? ( + <img + src={`/images/${user.avatar_id}.png`} + alt="User Avatar" + className={styles.avatarImg} + /> + ) : ( + <div className={styles.avatarPlaceholder}> + {user.disp_name?.[0] || "U"} + </div> + )} + </div> + + <div className={styles.userInfo}> + <h1 className={styles.name}>{user.disp_name || user.nickname}</h1> + <p className={styles.nickname}>@{user.nickname}</p> + {/* <p className={styles.created}> + Joined: {new Date(user.creation_date).toLocaleDateString()} + </p> */} + </div> + + <div className={styles.content}> + {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>} + </div> + </div> + </div> + ); +}; + +export default UserPage; diff --git a/modules/frontend/src/types/list.ts b/modules/frontend/src/types/list.ts new file mode 100644 index 0000000..582da39 --- /dev/null +++ b/modules/frontend/src/types/list.ts @@ -0,0 +1,12 @@ +export interface PaginatedResult<TItem> { + items: TItem[]; + nextCursor?: string; +} + +export interface FetchParams { + search: string; + cursor?: string; +} + +export type FetchFunction<TItem> = + (params: FetchParams) => Promise<PaginatedResult<TItem>>; diff --git a/modules/frontend/vite.config.ts b/modules/frontend/vite.config.ts index 4cfbdd0..6c261e6 100644 --- a/modules/frontend/vite.config.ts +++ b/modules/frontend/vite.config.ts @@ -1,9 +1,13 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + tailwindcss() + ], server: { host: '127.0.0.1', port: 8083, diff --git a/modules/server/main.go b/modules/server/main.go deleted file mode 100644 index 775540b..0000000 --- a/modules/server/main.go +++ /dev/null @@ -1,78 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "os" - "reflect" - - "github.com/gin-gonic/gin" - "github.com/pelletier/go-toml/v2" - log "github.com/sirupsen/logrus" -) - -var AppConfig Config - -func main() { - if len(os.Args) != 2 { - AppConfig.Mode = "env" - } else { - AppConfig.Mode = "argv" - } - - err := InitConfig() - if err != nil { - log.Fatalf("Failed to init config: %v\n", err) - } - - r := gin.Default() - - r.LoadHTMLGlob("templates/*") - - r.GET("/", func(c *gin.Context) { - c.HTML(http.StatusOK, "index.html", gin.H{ - "title": "Welcome Page", - "message": "Hello, Gin with HTML templates!", - }) - }) - - r.Run(":8080") -} - -func InitConfig() error { - if AppConfig.Mode == "argv" { - content, err := os.ReadFile(os.Args[1]) - if err != nil { - return err - } - - toml.Unmarshal(content, &AppConfig) - - fmt.Printf("%+v\n", AppConfig) - - return nil - } else if AppConfig.Mode == "env" { - f := reflect.ValueOf(AppConfig) - - for i := 0; i < f.NumField(); i++ { - field := f.Type().Field(i) - tag := field.Tag - env_var := tag.Get("env") - fmt.Printf("Field: %v.\nEnvironment variable: %v.\n", field.Name, env_var) - if env_var != "" { - env_value, exists := os.LookupEnv(env_var) - if !exists { - return fmt.Errorf("there is no env variable %s", env_var) - } - err := setField(&AppConfig, field.Name, env_value) - if err != nil { - return fmt.Errorf("failed to set config field %s: %v", field.Name, err) - } - } - } - - return nil - } else { - return fmt.Errorf("incorrect config mode") - } -} diff --git a/modules/server/templates/index.html b/modules/server/templates/index.html deleted file mode 100644 index 9f7bd75..0000000 --- a/modules/server/templates/index.html +++ /dev/null @@ -1,10 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <title>{{ .title }} - - -

{{ .message }}

- - \ No newline at end of file diff --git a/modules/server/types.go b/modules/server/types.go deleted file mode 100644 index 539babf..0000000 --- a/modules/server/types.go +++ /dev/null @@ -1,6 +0,0 @@ -package main - -type Config struct { - Mode string - LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` -} diff --git a/sql/db.go b/sql/db.go new file mode 100644 index 0000000..7a56507 --- /dev/null +++ b/sql/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/sql/migrations/000001_init.down.sql b/sql/migrations/000001_init.down.sql new file mode 100644 index 0000000..dc52d23 --- /dev/null +++ b/sql/migrations/000001_init.down.sql @@ -0,0 +1,20 @@ +DROP TRIGGER IF EXISTS trg_update_title_rating ON usertitles; +DROP TRIGGER IF EXISTS trg_notify_new_signal ON signals; + +DROP FUNCTION IF EXISTS update_title_rating(); +DROP FUNCTION IF EXISTS notify_new_signal(); + +DROP TABLE IF EXISTS signals; +DROP TABLE IF EXISTS title_tags; +DROP TABLE IF EXISTS usertitles; +DROP TABLE IF EXISTS titles; +DROP TABLE IF EXISTS studios; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS images; +DROP TABLE IF EXISTS tags; +DROP TABLE IF EXISTS providers; + +DROP TYPE IF EXISTS release_season_t; +DROP TYPE IF EXISTS title_status_t; +DROP TYPE IF EXISTS storage_type_t; +DROP TYPE IF EXISTS usertitle_status_t; \ No newline at end of file diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql new file mode 100644 index 0000000..49cca3d --- /dev/null +++ b/sql/migrations/000001_init.up.sql @@ -0,0 +1,172 @@ +-- TODO: +-- maybe jsonb constraints +-- clean unused images +CREATE TYPE usertitle_status_t AS ENUM ('finished', 'planned', 'dropped', 'in-progress'); +CREATE TYPE storage_type_t AS ENUM ('local', 's3'); +CREATE TYPE title_status_t AS ENUM ('finished', 'ongoing', 'planned'); +CREATE TYPE release_season_t AS ENUM ('winter', 'spring', 'summer', 'fall'); + +CREATE TABLE providers ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + provider_name text NOT NULL, + credentials jsonb +); + +CREATE TABLE tags ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + -- example: { "ru": "Сёдзё", "en": "Shojo", "jp": "少女"} + tag_names jsonb NOT NULL +); + +CREATE TABLE images ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + storage_type storage_type_t NOT NULL, + image_path text UNIQUE NOT NULL +); + +CREATE TABLE reviews ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + data text NOT NULL, + rating int CHECK (rating >= 0 AND rating <= 10), + user_id bigint REFERENCES users (id), + title_id bigint REFERENCES titles (id), + created_at timestamptz DEFAULT NOW() +); + +CREATE TABLE review_images ( + PRIMARY KEY (review_id, image_id), + review_id bigint NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, + image_id bigint NOT NULL REFERENCES images(id) ON DELETE CASCADE +); + +CREATE TABLE users ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + avatar_id bigint REFERENCES images (id), + passhash text NOT NULL, + mail text CHECK (mail ~ '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+$'), + nickname text UNIQUE NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]{3,}$'), + disp_name text, + user_desc text, + creation_date timestamptz NOT NULL, + last_login timestamptz +); + +CREATE TABLE studios ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + studio_name text NOT NULL UNIQUE, + illust_id bigint REFERENCES images (id), + studio_desc text +); + +CREATE TABLE titles ( + // TODO: anime type (film, season etc) + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + -- example {"ru": ["Атака титанов", "Атака Титанов"],"en": ["Attack on Titan", "AoT"],"ja": ["進撃の巨人", "しんげきのきょじん"]} + title_names jsonb NOT NULL, + studio_id bigint NOT NULL REFERENCES studios (id), + poster_id bigint REFERENCES images (id), + title_status title_status_t NOT NULL, + rating float CHECK (rating >= 0 AND rating <= 10), + rating_count int CHECK (rating_count >= 0), + release_year int CHECK (release_year >= 1900), + release_season release_season_t, + season int CHECK (season >= 0), + episodes_aired int CHECK (episodes_aired >= 0), + episodes_all int CHECK (episodes_all >= 0), + -- example { "1": "50.50", "2": "23.23"} + episodes_len jsonb, + CHECK ((episodes_aired IS NULL AND episodes_all IS NULL) + OR (episodes_aired IS NOT NULL AND episodes_all IS NOT NULL + AND episodes_aired <= episodes_all)) +); + +CREATE TABLE usertitles ( + PRIMARY KEY (user_id, title_id), + user_id bigint NOT NULL REFERENCES users (id), + title_id bigint NOT NULL REFERENCES titles (id), + status usertitle_status_t NOT NULL, + rate int CHECK (rate > 0 AND rate <= 10), + review_id bigint REFERENCES reviews (id), + ctime timestamptz + -- // TODO: series status +); + +CREATE TABLE title_tags ( + PRIMARY KEY (title_id, tag_id), + title_id bigint NOT NULL REFERENCES titles (id), + tag_id bigint NOT NULL REFERENCES tags (id) +); + +CREATE TABLE signals ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + title_id bigint REFERENCES titles (id), + raw_data jsonb NOT NULL, + provider_id bigint NOT NULL REFERENCES providers (id), + pending boolean NOT NULL +); + +CREATE TABLE external_ids ( + user_id bigint NOT NULL REFERENCES users (id), + service_id bigint REFERENCES external_services (id), + external_id text NOT NULL +); + +CREATE TABLE external_services ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text UNIQUE NOT NULL +); + +-- Functions +CREATE OR REPLACE FUNCTION update_title_rating() +RETURNS TRIGGER AS $$ +BEGIN + IF (TG_OP = 'INSERT') OR (TG_OP = 'UPDATE' AND NEW.rate IS DISTINCT FROM OLD.rate) THEN + UPDATE titles + SET + rating = sub.avg_rating, + rating_count = sub.rating_count + FROM ( + SELECT + title_id, + AVG(rate)::float AS avg_rating, + COUNT(rate) AS rating_count + FROM usertitles + WHERE title_id = NEW.title_id AND rate IS NOT NULL + GROUP BY title_id + ) AS sub + WHERE titles.id = sub.title_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION notify_new_signal() +RETURNS TRIGGER AS $$ +DECLARE + payload JSON; +BEGIN + payload := json_build_object( + 'signal_id', NEW.id, + 'title_id', NEW.title_id, + 'provider_id', NEW.provider_id, + 'pending', NEW.pending, + 'timestamp', NOW() + ); + PERFORM pg_notify('new_signal', payload::text); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Triggers + +CREATE TRIGGER trg_update_title_rating +AFTER INSERT OR UPDATE OF rate ON usertitles +FOR EACH ROW +EXECUTE FUNCTION update_title_rating(); + +CREATE TRIGGER trg_notify_new_signal +AFTER INSERT ON signals +FOR EACH ROW +EXECUTE FUNCTION notify_new_signal(); \ No newline at end of file diff --git a/sql/models.go b/sql/models.go new file mode 100644 index 0000000..a593504 --- /dev/null +++ b/sql/models.go @@ -0,0 +1,286 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlc + +import ( + "database/sql/driver" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +type ReleaseSeasonT string + +const ( + ReleaseSeasonTWinter ReleaseSeasonT = "winter" + ReleaseSeasonTSpring ReleaseSeasonT = "spring" + ReleaseSeasonTSummer ReleaseSeasonT = "summer" + ReleaseSeasonTFall ReleaseSeasonT = "fall" +) + +func (e *ReleaseSeasonT) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = ReleaseSeasonT(s) + case string: + *e = ReleaseSeasonT(s) + default: + return fmt.Errorf("unsupported scan type for ReleaseSeasonT: %T", src) + } + return nil +} + +type NullReleaseSeasonT struct { + ReleaseSeasonT ReleaseSeasonT `json:"release_season_t"` + Valid bool `json:"valid"` // Valid is true if ReleaseSeasonT is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullReleaseSeasonT) Scan(value interface{}) error { + if value == nil { + ns.ReleaseSeasonT, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.ReleaseSeasonT.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullReleaseSeasonT) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.ReleaseSeasonT), nil +} + +type StorageTypeT string + +const ( + StorageTypeTLocal StorageTypeT = "local" + StorageTypeTS3 StorageTypeT = "s3" +) + +func (e *StorageTypeT) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = StorageTypeT(s) + case string: + *e = StorageTypeT(s) + default: + return fmt.Errorf("unsupported scan type for StorageTypeT: %T", src) + } + return nil +} + +type NullStorageTypeT struct { + StorageTypeT StorageTypeT `json:"storage_type_t"` + Valid bool `json:"valid"` // Valid is true if StorageTypeT is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullStorageTypeT) Scan(value interface{}) error { + if value == nil { + ns.StorageTypeT, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.StorageTypeT.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullStorageTypeT) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.StorageTypeT), nil +} + +type TitleStatusT string + +const ( + TitleStatusTFinished TitleStatusT = "finished" + TitleStatusTOngoing TitleStatusT = "ongoing" + TitleStatusTPlanned TitleStatusT = "planned" +) + +func (e *TitleStatusT) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = TitleStatusT(s) + case string: + *e = TitleStatusT(s) + default: + return fmt.Errorf("unsupported scan type for TitleStatusT: %T", src) + } + return nil +} + +type NullTitleStatusT struct { + TitleStatusT TitleStatusT `json:"title_status_t"` + Valid bool `json:"valid"` // Valid is true if TitleStatusT is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullTitleStatusT) Scan(value interface{}) error { + if value == nil { + ns.TitleStatusT, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.TitleStatusT.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullTitleStatusT) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.TitleStatusT), nil +} + +type UsertitleStatusT string + +const ( + UsertitleStatusTFinished UsertitleStatusT = "finished" + UsertitleStatusTPlanned UsertitleStatusT = "planned" + UsertitleStatusTDropped UsertitleStatusT = "dropped" + UsertitleStatusTInProgress UsertitleStatusT = "in-progress" +) + +func (e *UsertitleStatusT) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = UsertitleStatusT(s) + case string: + *e = UsertitleStatusT(s) + default: + return fmt.Errorf("unsupported scan type for UsertitleStatusT: %T", src) + } + return nil +} + +type NullUsertitleStatusT struct { + UsertitleStatusT UsertitleStatusT `json:"usertitle_status_t"` + Valid bool `json:"valid"` // Valid is true if UsertitleStatusT is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullUsertitleStatusT) Scan(value interface{}) error { + if value == nil { + ns.UsertitleStatusT, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.UsertitleStatusT.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullUsertitleStatusT) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.UsertitleStatusT), nil +} + +type ExternalID struct { + UserID int64 `json:"user_id"` + ServiceID *int64 `json:"service_id"` + ExternalID string `json:"external_id"` +} + +type ExternalService struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +type Image struct { + ID int64 `json:"id"` + StorageType StorageTypeT `json:"storage_type"` + ImagePath string `json:"image_path"` +} + +type Provider struct { + ID int64 `json:"id"` + ProviderName string `json:"provider_name"` + Credentials []byte `json:"credentials"` +} + +type Review struct { + ID int64 `json:"id"` + Data string `json:"data"` + Rating *int32 `json:"rating"` + IllustID *int64 `json:"illust_id"` + UserID *int64 `json:"user_id"` + TitleID *int64 `json:"title_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type ReviewImage struct { + ReviewID int64 `json:"review_id"` + ImageID int64 `json:"image_id"` +} + +type Signal struct { + ID int64 `json:"id"` + TitleID *int64 `json:"title_id"` + RawData []byte `json:"raw_data"` + ProviderID int64 `json:"provider_id"` + Pending bool `json:"pending"` +} + +type Studio struct { + ID int64 `json:"id"` + StudioName string `json:"studio_name"` + IllustID *int64 `json:"illust_id"` + StudioDesc *string `json:"studio_desc"` +} + +type Tag struct { + ID int64 `json:"id"` + TagNames []byte `json:"tag_names"` +} + +type Title struct { + ID int64 `json:"id"` + TitleNames []byte `json:"title_names"` + StudioID int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` +} + +type TitleTag struct { + TitleID int64 `json:"title_id"` + TagID int64 `json:"tag_id"` +} + +type User struct { + ID int64 `json:"id"` + AvatarID *int64 `json:"avatar_id"` + Passhash string `json:"passhash"` + Mail *string `json:"mail"` + Nickname string `json:"nickname"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + CreationDate time.Time `json:"creation_date"` + LastLogin pgtype.Timestamptz `json:"last_login"` +} + +type Usertitle struct { + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewText *string `json:"review_text"` + ReviewDate pgtype.Timestamptz `json:"review_date"` +} diff --git a/sql/queries.sql.go b/sql/queries.sql.go new file mode 100644 index 0000000..c5e6f8a --- /dev/null +++ b/sql/queries.sql.go @@ -0,0 +1,370 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: queries.sql + +package sqlc + +import ( + "context" + "time" +) + +const createImage = `-- name: CreateImage :one +INSERT INTO images (storage_type, image_path) +VALUES ($1, $2) +RETURNING id, storage_type, image_path +` + +type CreateImageParams struct { + StorageType StorageTypeT `json:"storage_type"` + ImagePath string `json:"image_path"` +} + +func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Image, error) { + row := q.db.QueryRow(ctx, createImage, arg.StorageType, arg.ImagePath) + var i Image + err := row.Scan(&i.ID, &i.StorageType, &i.ImagePath) + return i, err +} + +const getImageByID = `-- name: GetImageByID :one +SELECT id, storage_type, image_path +FROM images +WHERE id = $1 +` + +func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, error) { + row := q.db.QueryRow(ctx, getImageByID, illustID) + var i Image + err := row.Scan(&i.ID, &i.StorageType, &i.ImagePath) + return i, err +} + +const getReviewByID = `-- name: GetReviewByID :one + + +SELECT id, data, rating, illust_id, user_id, title_id, created_at +FROM reviews +WHERE review_id = $1::bigint +` + +// -- name: ListTitles :many +// SELECT title_id, title_names, studio_id, poster_id, signal_ids, +// +// title_status, rating, rating_count, release_year, release_season, +// season, episodes_aired, episodes_all, episodes_len +// +// FROM titles +// ORDER BY title_id +// LIMIT $1 OFFSET $2; +// -- name: UpdateTitle :one +// UPDATE titles +// SET +// +// title_names = COALESCE(sqlc.narg('title_names'), title_names), +// studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), +// poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), +// signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), +// title_status = COALESCE(sqlc.narg('title_status'), title_status), +// release_year = COALESCE(sqlc.narg('release_year'), release_year), +// release_season = COALESCE(sqlc.narg('release_season'), release_season), +// episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), +// episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), +// episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) +// +// WHERE title_id = sqlc.arg('title_id') +// RETURNING *; +func (q *Queries) GetReviewByID(ctx context.Context, reviewID int64) (Review, error) { + row := q.db.QueryRow(ctx, getReviewByID, reviewID) + var i Review + err := row.Scan( + &i.ID, + &i.Data, + &i.Rating, + &i.IllustID, + &i.UserID, + &i.TitleID, + &i.CreatedAt, + ) + return i, err +} + +const getStudioByID = `-- name: GetStudioByID :one +SELECT id, studio_name, illust_id, studio_desc +FROM studios +WHERE id = $1::bigint +` + +func (q *Queries) GetStudioByID(ctx context.Context, studioID int64) (Studio, error) { + row := q.db.QueryRow(ctx, getStudioByID, studioID) + var i Studio + err := row.Scan( + &i.ID, + &i.StudioName, + &i.IllustID, + &i.StudioDesc, + ) + return i, err +} + +const getTitleByID = `-- name: GetTitleByID :one + + + + +SELECT id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len +FROM titles +WHERE id = $1::bigint +` + +// -- name: ListUsers :many +// SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date +// FROM users +// ORDER BY user_id +// LIMIT $1 OFFSET $2; +// -- name: CreateUser :one +// INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) +// VALUES ($1, $2, $3, $4, $5, $6, $7) +// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; +// -- name: UpdateUser :one +// UPDATE users +// SET +// +// avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), +// disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), +// user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), +// passhash = COALESCE(sqlc.narg('passhash'), passhash) +// +// WHERE user_id = sqlc.arg('user_id') +// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; +// -- name: DeleteUser :exec +// DELETE FROM users +// WHERE user_id = $1; +func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (Title, error) { + row := q.db.QueryRow(ctx, getTitleByID, titleID) + var i Title + err := row.Scan( + &i.ID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, + ) + return i, err +} + +const getTitleTags = `-- name: GetTitleTags :many +SELECT + tag_names +FROM tags as g +JOIN title_tags as t ON(t.tag_id = g.id) +WHERE t.title_id = $1::bigint +` + +func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([][]byte, error) { + rows, err := q.db.Query(ctx, getTitleTags, titleID) + if err != nil { + return nil, err + } + defer rows.Close() + var items [][]byte + for rows.Next() { + var tag_names []byte + if err := rows.Scan(&tag_names); err != nil { + return nil, err + } + items = append(items, tag_names) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date +FROM users +WHERE id = $1 +` + +type GetUserByIDRow struct { + ID int64 `json:"id"` + AvatarID *int64 `json:"avatar_id"` + Mail *string `json:"mail"` + Nickname string `json:"nickname"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + CreationDate time.Time `json:"creation_date"` +} + +func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error) { + row := q.db.QueryRow(ctx, getUserByID, id) + var i GetUserByIDRow + err := row.Scan( + &i.ID, + &i.AvatarID, + &i.Mail, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + ) + return i, err +} + +const insertStudio = `-- name: InsertStudio :one +INSERT INTO studios (studio_name, illust_id, studio_desc) +VALUES ( + $1::text, + $2::bigint, + $3::text) +RETURNING id, studio_name, illust_id, studio_desc +` + +type InsertStudioParams struct { + StudioName string `json:"studio_name"` + IllustID *int64 `json:"illust_id"` + StudioDesc *string `json:"studio_desc"` +} + +func (q *Queries) InsertStudio(ctx context.Context, arg InsertStudioParams) (Studio, error) { + row := q.db.QueryRow(ctx, insertStudio, arg.StudioName, arg.IllustID, arg.StudioDesc) + var i Studio + err := row.Scan( + &i.ID, + &i.StudioName, + &i.IllustID, + &i.StudioDesc, + ) + return i, err +} + +const insertTag = `-- name: InsertTag :one +INSERT INTO tags (tag_names) +VALUES ( + $1::jsonb) +RETURNING id, tag_names +` + +func (q *Queries) InsertTag(ctx context.Context, tagNames []byte) (Tag, error) { + row := q.db.QueryRow(ctx, insertTag, tagNames) + var i Tag + err := row.Scan(&i.ID, &i.TagNames) + return i, err +} + +const insertTitleTags = `-- name: InsertTitleTags :one +INSERT INTO title_tags (title_id, tag_id) +VALUES ( + $1::bigint, + $2::bigint) +RETURNING title_id, tag_id +` + +type InsertTitleTagsParams struct { + TitleID int64 `json:"title_id"` + TagID int64 `json:"tag_id"` +} + +func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams) (TitleTag, error) { + row := q.db.QueryRow(ctx, insertTitleTags, arg.TitleID, arg.TagID) + var i TitleTag + err := row.Scan(&i.TitleID, &i.TagID) + return i, err +} + +const searchTitles = `-- name: SearchTitles :many +SELECT + id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len +FROM titles +WHERE + CASE + WHEN $1::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array($1::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + + AND ($2::title_status_t IS NULL OR title_status = $2::title_status_t) + AND ($3::float IS NULL OR rating >= $3::float) + AND ($4::int IS NULL OR release_year = $4::int) + AND ($5::release_season_t IS NULL OR release_season = $5::release_season_t) + +LIMIT COALESCE($7::int, 100) -- 100 is default limit +OFFSET $6::int +` + +type SearchTitlesParams struct { + Word *string `json:"word"` + Status *TitleStatusT `json:"status"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Offset *int32 `json:"offset"` + Limit *int32 `json:"limit"` +} + +func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]Title, error) { + rows, err := q.db.Query(ctx, searchTitles, + arg.Word, + arg.Status, + arg.Rating, + arg.ReleaseYear, + arg.ReleaseSeason, + arg.Offset, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Title + for rows.Next() { + var i Title + if err := rows.Scan( + &i.ID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml new file mode 100644 index 0000000..f74d2ad --- /dev/null +++ b/sql/sqlc.yaml @@ -0,0 +1,37 @@ +version: "2" +sql: + - engine: "postgresql" + queries: + - "../modules/backend/queries.sql" + schema: "migrations" + gen: + go: + package: "sqlc" + out: "." + sql_package: "pgx/v5" + sql_driver: "github.com/jackc/pgx/v5" + emit_json_tags: true + emit_pointers_for_null_types: true + overrides: + - db_type: "uuid" + nullable: false + go_type: + import: "github.com/gofrs/uuid" + package: "gofrsuuid" + type: UUID + pointer: true + - db_type: "timestamptz" + nullable: false + go_type: + import: "time" + type: "Time" + - db_type: "title_status_t" + nullable: true + go_type: + pointer: true + type: "TitleStatusT" + - db_type: "release_season_t" + nullable: true + go_type: + pointer: true + type: "ReleaseSeasonT" \ No newline at end of file