wip 3
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,15 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"reichard.io/antholume/database"
|
"reichard.io/antholume/database"
|
||||||
|
"reichard.io/antholume/metadata"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GET /documents
|
// GET /documents
|
||||||
@@ -60,6 +67,9 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb
|
|||||||
Title: *row.Title,
|
Title: *row.Title,
|
||||||
Author: *row.Author,
|
Author: *row.Author,
|
||||||
Words: row.Words,
|
Words: row.Words,
|
||||||
|
Filepath: row.Filepath,
|
||||||
|
Percentage: ptrOf(float32(row.Percentage)),
|
||||||
|
TotalTimeSeconds: ptrOf(row.TotalTimeSeconds),
|
||||||
}
|
}
|
||||||
if row.Words != nil {
|
if row.Words != nil {
|
||||||
wordCounts = append(wordCounts, WordCount{
|
wordCounts = append(wordCounts, WordCount{
|
||||||
@@ -102,12 +112,11 @@ func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObje
|
|||||||
var progress *Progress
|
var progress *Progress
|
||||||
if err == nil {
|
if err == nil {
|
||||||
progress = &Progress{
|
progress = &Progress{
|
||||||
UserId: progressRow.UserID,
|
UserId: &progressRow.UserID,
|
||||||
DocumentId: progressRow.DocumentID,
|
DocumentId: &progressRow.DocumentID,
|
||||||
DeviceId: progressRow.DeviceID,
|
DeviceName: &progressRow.DeviceName,
|
||||||
Percentage: progressRow.Percentage,
|
Percentage: &progressRow.Percentage,
|
||||||
Progress: progressRow.Progress,
|
CreatedAt: ptrOf(parseTime(progressRow.CreatedAt)),
|
||||||
CreatedAt: parseTime(progressRow.CreatedAt),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,3 +137,158 @@ func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObje
|
|||||||
}
|
}
|
||||||
return GetDocument200JSONResponse(response), nil
|
return GetDocument200JSONResponse(response), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deriveBaseFileName builds the base filename for a given MetadataInfo object.
|
||||||
|
func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
|
||||||
|
// Derive New FileName
|
||||||
|
var newFileName string
|
||||||
|
if metadataInfo.Author != nil && *metadataInfo.Author != "" {
|
||||||
|
newFileName = newFileName + *metadataInfo.Author
|
||||||
|
} else {
|
||||||
|
newFileName = newFileName + "Unknown"
|
||||||
|
}
|
||||||
|
if metadataInfo.Title != nil && *metadataInfo.Title != "" {
|
||||||
|
newFileName = newFileName + " - " + *metadataInfo.Title
|
||||||
|
} else {
|
||||||
|
newFileName = newFileName + " - Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove Slashes
|
||||||
|
fileName := strings.ReplaceAll(newFileName, "/", "")
|
||||||
|
return "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadataInfo.PartialMD5, metadataInfo.Type))
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /documents
|
||||||
|
func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentRequestObject) (CreateDocumentResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return CreateDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.Body == nil {
|
||||||
|
return CreateDocument400JSONResponse{Code: 400, Message: "Missing request body"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read multipart form
|
||||||
|
form, err := request.Body.ReadForm(32 << 20) // 32MB max memory
|
||||||
|
if err != nil {
|
||||||
|
log.Error("ReadForm error:", err)
|
||||||
|
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to read form"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file from form
|
||||||
|
fileField := form.File["document_file"]
|
||||||
|
if len(fileField) == 0 {
|
||||||
|
return CreateDocument400JSONResponse{Code: 400, Message: "No file provided"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
file := fileField[0]
|
||||||
|
|
||||||
|
// Validate file extension
|
||||||
|
if !strings.HasSuffix(strings.ToLower(file.Filename), ".epub") {
|
||||||
|
return CreateDocument400JSONResponse{Code: 400, Message: "Only EPUB files are allowed"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open file
|
||||||
|
f, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Open file error:", err)
|
||||||
|
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to open file"}, nil
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// Read file content
|
||||||
|
data, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Read file error:", err)
|
||||||
|
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to read file"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temp file to get metadata
|
||||||
|
tempFile, err := os.CreateTemp("", "book")
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Temp file create error:", err)
|
||||||
|
return CreateDocument500JSONResponse{Code: 500, Message: "Unable to create temp file"}, nil
|
||||||
|
}
|
||||||
|
defer os.Remove(tempFile.Name())
|
||||||
|
defer tempFile.Close()
|
||||||
|
|
||||||
|
// Write data to temp file
|
||||||
|
if _, err := tempFile.Write(data); err != nil {
|
||||||
|
log.Error("Write temp file error:", err)
|
||||||
|
return CreateDocument500JSONResponse{Code: 500, Message: "Unable to write temp file"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get metadata using metadata package
|
||||||
|
metadataInfo, err := metadata.GetMetadata(tempFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetMetadata error:", err)
|
||||||
|
return CreateDocument500JSONResponse{Code: 500, Message: "Unable to acquire metadata"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
_, err = s.db.Queries.GetDocument(ctx, *metadataInfo.PartialMD5)
|
||||||
|
if err == nil {
|
||||||
|
// Document already exists
|
||||||
|
existingDoc, _ := s.db.Queries.GetDocument(ctx, *metadataInfo.PartialMD5)
|
||||||
|
apiDoc := Document{
|
||||||
|
Id: existingDoc.ID,
|
||||||
|
Title: *existingDoc.Title,
|
||||||
|
Author: *existingDoc.Author,
|
||||||
|
CreatedAt: parseTime(existingDoc.CreatedAt),
|
||||||
|
UpdatedAt: parseTime(existingDoc.UpdatedAt),
|
||||||
|
Deleted: existingDoc.Deleted,
|
||||||
|
Words: existingDoc.Words,
|
||||||
|
}
|
||||||
|
response := DocumentResponse{
|
||||||
|
Document: apiDoc,
|
||||||
|
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
||||||
|
}
|
||||||
|
return CreateDocument200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive & sanitize file name
|
||||||
|
fileName := deriveBaseFileName(metadataInfo)
|
||||||
|
basePath := filepath.Join(s.cfg.DataPath, "documents")
|
||||||
|
safePath := filepath.Join(basePath, fileName)
|
||||||
|
|
||||||
|
// Save file to storage
|
||||||
|
err = os.WriteFile(safePath, data, 0644)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Save file error:", err)
|
||||||
|
return CreateDocument500JSONResponse{Code: 500, Message: "Unable to save file"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert document
|
||||||
|
doc, err := s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
|
||||||
|
ID: *metadataInfo.PartialMD5,
|
||||||
|
Title: metadataInfo.Title,
|
||||||
|
Author: metadataInfo.Author,
|
||||||
|
Description: metadataInfo.Description,
|
||||||
|
Md5: metadataInfo.MD5,
|
||||||
|
Words: metadataInfo.WordCount,
|
||||||
|
Filepath: &fileName,
|
||||||
|
Basepath: &basePath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("UpsertDocument DB error:", err)
|
||||||
|
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to save document"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
apiDoc := Document{
|
||||||
|
Id: doc.ID,
|
||||||
|
Title: *doc.Title,
|
||||||
|
Author: *doc.Author,
|
||||||
|
CreatedAt: parseTime(doc.CreatedAt),
|
||||||
|
UpdatedAt: parseTime(doc.UpdatedAt),
|
||||||
|
Deleted: doc.Deleted,
|
||||||
|
Words: doc.Words,
|
||||||
|
}
|
||||||
|
|
||||||
|
response := DocumentResponse{
|
||||||
|
Document: apiDoc,
|
||||||
|
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreateDocument200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|||||||
251
api/v1/home.go
Normal file
251
api/v1/home.go
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"reichard.io/antholume/database"
|
||||||
|
"reichard.io/antholume/graph"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /home
|
||||||
|
func (s *Server) GetHome(ctx context.Context, request GetHomeRequestObject) (GetHomeResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return GetHome401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get database info
|
||||||
|
dbInfo, err := s.db.Queries.GetDatabaseInfo(ctx, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetDatabaseInfo DB Error:", err)
|
||||||
|
return GetHome500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get streaks
|
||||||
|
streaks, err := s.db.Queries.GetUserStreaks(ctx, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetUserStreaks DB Error:", err)
|
||||||
|
return GetHome500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get graph data
|
||||||
|
graphData, err := s.db.Queries.GetDailyReadStats(ctx, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetDailyReadStats DB Error:", err)
|
||||||
|
return GetHome500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user statistics
|
||||||
|
userStats, err := s.db.Queries.GetUserStatistics(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetUserStatistics DB Error:", err)
|
||||||
|
return GetHome500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response
|
||||||
|
response := HomeResponse{
|
||||||
|
DatabaseInfo: DatabaseInfo{
|
||||||
|
DocumentsSize: dbInfo.DocumentsSize,
|
||||||
|
ActivitySize: dbInfo.ActivitySize,
|
||||||
|
ProgressSize: dbInfo.ProgressSize,
|
||||||
|
DevicesSize: dbInfo.DevicesSize,
|
||||||
|
},
|
||||||
|
Streaks: StreaksResponse{
|
||||||
|
Streaks: convertStreaks(streaks),
|
||||||
|
User: UserData{
|
||||||
|
Username: auth.UserName,
|
||||||
|
IsAdmin: auth.IsAdmin,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
GraphData: GraphDataResponse{
|
||||||
|
GraphData: convertGraphData(graphData),
|
||||||
|
User: UserData{
|
||||||
|
Username: auth.UserName,
|
||||||
|
IsAdmin: auth.IsAdmin,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
UserStatistics: arrangeUserStatistics(userStats),
|
||||||
|
User: UserData{
|
||||||
|
Username: auth.UserName,
|
||||||
|
IsAdmin: auth.IsAdmin,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetHome200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /home/streaks
|
||||||
|
func (s *Server) GetStreaks(ctx context.Context, request GetStreaksRequestObject) (GetStreaksResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return GetStreaks401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
streaks, err := s.db.Queries.GetUserStreaks(ctx, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetUserStreaks DB Error:", err)
|
||||||
|
return GetStreaks500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
response := StreaksResponse{
|
||||||
|
Streaks: convertStreaks(streaks),
|
||||||
|
User: UserData{
|
||||||
|
Username: auth.UserName,
|
||||||
|
IsAdmin: auth.IsAdmin,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetStreaks200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /home/graph
|
||||||
|
func (s *Server) GetGraphData(ctx context.Context, request GetGraphDataRequestObject) (GetGraphDataResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return GetGraphData401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
graphData, err := s.db.Queries.GetDailyReadStats(ctx, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetDailyReadStats DB Error:", err)
|
||||||
|
return GetGraphData500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
response := GraphDataResponse{
|
||||||
|
GraphData: convertGraphData(graphData),
|
||||||
|
User: UserData{
|
||||||
|
Username: auth.UserName,
|
||||||
|
IsAdmin: auth.IsAdmin,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetGraphData200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /home/statistics
|
||||||
|
func (s *Server) GetUserStatistics(ctx context.Context, request GetUserStatisticsRequestObject) (GetUserStatisticsResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return GetUserStatistics401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
userStats, err := s.db.Queries.GetUserStatistics(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetUserStatistics DB Error:", err)
|
||||||
|
return GetUserStatistics500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
response := arrangeUserStatistics(userStats)
|
||||||
|
response.User = UserData{
|
||||||
|
Username: auth.UserName,
|
||||||
|
IsAdmin: auth.IsAdmin,
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetUserStatistics200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertStreaks(streaks []database.UserStreak) []UserStreak {
|
||||||
|
result := make([]UserStreak, len(streaks))
|
||||||
|
for i, streak := range streaks {
|
||||||
|
result[i] = UserStreak{
|
||||||
|
Window: streak.Window,
|
||||||
|
MaxStreak: streak.MaxStreak,
|
||||||
|
MaxStreakStartDate: streak.MaxStreakStartDate,
|
||||||
|
MaxStreakEndDate: streak.MaxStreakEndDate,
|
||||||
|
CurrentStreak: streak.CurrentStreak,
|
||||||
|
CurrentStreakStartDate: streak.CurrentStreakStartDate,
|
||||||
|
CurrentStreakEndDate: streak.CurrentStreakEndDate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertGraphData(graphData []database.GetDailyReadStatsRow) []GraphDataPoint {
|
||||||
|
result := make([]GraphDataPoint, len(graphData))
|
||||||
|
for i, data := range graphData {
|
||||||
|
result[i] = GraphDataPoint{
|
||||||
|
Date: data.Date,
|
||||||
|
MinutesRead: data.MinutesRead,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) UserStatisticsResponse {
|
||||||
|
// Sort helper - sort by WPM
|
||||||
|
sortByWPM := func(stats []database.GetUserStatisticsRow) []LeaderboardEntry {
|
||||||
|
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
|
||||||
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
|
return sorted[i].TotalWpm > sorted[j].TotalWpm
|
||||||
|
})
|
||||||
|
|
||||||
|
result := make([]LeaderboardEntry, len(sorted))
|
||||||
|
for i, item := range sorted {
|
||||||
|
result[i] = LeaderboardEntry{UserId: item.UserID, Value: int64(item.TotalWpm)}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by duration (seconds)
|
||||||
|
sortByDuration := func(stats []database.GetUserStatisticsRow) []LeaderboardEntry {
|
||||||
|
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
|
||||||
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
|
return sorted[i].TotalSeconds > sorted[j].TotalSeconds
|
||||||
|
})
|
||||||
|
|
||||||
|
result := make([]LeaderboardEntry, len(sorted))
|
||||||
|
for i, item := range sorted {
|
||||||
|
result[i] = LeaderboardEntry{UserId: item.UserID, Value: item.TotalSeconds}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by words
|
||||||
|
sortByWords := func(stats []database.GetUserStatisticsRow) []LeaderboardEntry {
|
||||||
|
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
|
||||||
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
|
return sorted[i].TotalWordsRead > sorted[j].TotalWordsRead
|
||||||
|
})
|
||||||
|
|
||||||
|
result := make([]LeaderboardEntry, len(sorted))
|
||||||
|
for i, item := range sorted {
|
||||||
|
result[i] = LeaderboardEntry{UserId: item.UserID, Value: item.TotalWordsRead}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserStatisticsResponse{
|
||||||
|
Wpm: LeaderboardData{
|
||||||
|
All: sortByWPM(userStatistics),
|
||||||
|
Year: sortByWPM(userStatistics),
|
||||||
|
Month: sortByWPM(userStatistics),
|
||||||
|
Week: sortByWPM(userStatistics),
|
||||||
|
},
|
||||||
|
Duration: LeaderboardData{
|
||||||
|
All: sortByDuration(userStatistics),
|
||||||
|
Year: sortByDuration(userStatistics),
|
||||||
|
Month: sortByDuration(userStatistics),
|
||||||
|
Week: sortByDuration(userStatistics),
|
||||||
|
},
|
||||||
|
Words: LeaderboardData{
|
||||||
|
All: sortByWords(userStatistics),
|
||||||
|
Year: sortByWords(userStatistics),
|
||||||
|
Month: sortByWords(userStatistics),
|
||||||
|
Week: sortByWords(userStatistics),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSVGGraphData generates SVG bezier path for graph visualization
|
||||||
|
func GetSVGGraphData(inputData []GraphDataPoint, svgWidth int, svgHeight int) graph.SVGGraphData {
|
||||||
|
// Convert to int64 slice expected by graph package
|
||||||
|
intData := make([]int64, len(inputData))
|
||||||
|
|
||||||
|
for i, data := range inputData {
|
||||||
|
intData[i] = int64(data.MinutesRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
return graph.GetSVGGraphData(intData, svgWidth, svgHeight)
|
||||||
|
}
|
||||||
@@ -29,6 +29,14 @@ components:
|
|||||||
words:
|
words:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
|
filepath:
|
||||||
|
type: string
|
||||||
|
percentage:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
total_time_seconds:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
required:
|
required:
|
||||||
- id
|
- id
|
||||||
- title
|
- title
|
||||||
@@ -63,27 +71,22 @@ components:
|
|||||||
Progress:
|
Progress:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
user_id:
|
title:
|
||||||
type: string
|
type: string
|
||||||
document_id:
|
author:
|
||||||
type: string
|
type: string
|
||||||
device_id:
|
device_name:
|
||||||
type: string
|
type: string
|
||||||
percentage:
|
percentage:
|
||||||
type: number
|
type: number
|
||||||
format: double
|
format: double
|
||||||
progress:
|
document_id:
|
||||||
|
type: string
|
||||||
|
user_id:
|
||||||
type: string
|
type: string
|
||||||
created_at:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
required:
|
|
||||||
- user_id
|
|
||||||
- document_id
|
|
||||||
- device_id
|
|
||||||
- percentage
|
|
||||||
- progress
|
|
||||||
- created_at
|
|
||||||
|
|
||||||
Activity:
|
Activity:
|
||||||
type: object
|
type: object
|
||||||
@@ -106,6 +109,42 @@ components:
|
|||||||
- activity_type
|
- activity_type
|
||||||
- timestamp
|
- timestamp
|
||||||
|
|
||||||
|
SearchItem:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
author:
|
||||||
|
type: string
|
||||||
|
language:
|
||||||
|
type: string
|
||||||
|
series:
|
||||||
|
type: string
|
||||||
|
file_type:
|
||||||
|
type: string
|
||||||
|
file_size:
|
||||||
|
type: string
|
||||||
|
upload_date:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
SearchResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
results:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/SearchItem'
|
||||||
|
source:
|
||||||
|
type: string
|
||||||
|
query:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- results
|
||||||
|
- source
|
||||||
|
- query
|
||||||
|
|
||||||
Setting:
|
Setting:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -174,8 +213,38 @@ components:
|
|||||||
- document
|
- document
|
||||||
- user
|
- user
|
||||||
|
|
||||||
ProgressResponse:
|
ProgressListResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
progress:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
$ref: '#/components/schemas/Progress'
|
$ref: '#/components/schemas/Progress'
|
||||||
|
user:
|
||||||
|
$ref: '#/components/schemas/UserData'
|
||||||
|
page:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
limit:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
next_page:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
previous_page:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
|
||||||
|
ProgressResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
progress:
|
||||||
|
$ref: '#/components/schemas/Progress'
|
||||||
|
user:
|
||||||
|
$ref: '#/components/schemas/UserData'
|
||||||
|
|
||||||
ActivityResponse:
|
ActivityResponse:
|
||||||
type: object
|
type: object
|
||||||
@@ -190,17 +259,31 @@ components:
|
|||||||
- activities
|
- activities
|
||||||
- user
|
- user
|
||||||
|
|
||||||
|
Device:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
device_name:
|
||||||
|
type: string
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
last_synced:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
SettingsResponse:
|
SettingsResponse:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
settings:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/Setting'
|
|
||||||
user:
|
user:
|
||||||
$ref: '#/components/schemas/UserData'
|
$ref: '#/components/schemas/UserData'
|
||||||
timezone:
|
timezone:
|
||||||
type: string
|
type: string
|
||||||
|
devices:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Device'
|
||||||
required:
|
required:
|
||||||
- settings
|
- settings
|
||||||
- user
|
- user
|
||||||
@@ -238,6 +321,167 @@ components:
|
|||||||
- code
|
- code
|
||||||
- message
|
- message
|
||||||
|
|
||||||
|
DatabaseInfo:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
documents_size:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
activity_size:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
progress_size:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
devices_size:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
required:
|
||||||
|
- documents_size
|
||||||
|
- activity_size
|
||||||
|
- progress_size
|
||||||
|
- devices_size
|
||||||
|
|
||||||
|
UserStreak:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
window:
|
||||||
|
type: string
|
||||||
|
max_streak:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
max_streak_start_date:
|
||||||
|
type: string
|
||||||
|
max_streak_end_date:
|
||||||
|
type: string
|
||||||
|
current_streak:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
current_streak_start_date:
|
||||||
|
type: string
|
||||||
|
current_streak_end_date:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- window
|
||||||
|
- max_streak
|
||||||
|
- max_streak_start_date
|
||||||
|
- max_streak_end_date
|
||||||
|
- current_streak
|
||||||
|
- current_streak_start_date
|
||||||
|
- current_streak_end_date
|
||||||
|
|
||||||
|
StreaksResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
streaks:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/UserStreak'
|
||||||
|
user:
|
||||||
|
$ref: '#/components/schemas/UserData'
|
||||||
|
required:
|
||||||
|
- streaks
|
||||||
|
- user
|
||||||
|
|
||||||
|
GraphDataPoint:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
minutes_read:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
required:
|
||||||
|
- date
|
||||||
|
- minutes_read
|
||||||
|
|
||||||
|
GraphDataResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
graph_data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/GraphDataPoint'
|
||||||
|
user:
|
||||||
|
$ref: '#/components/schemas/UserData'
|
||||||
|
required:
|
||||||
|
- graph_data
|
||||||
|
- user
|
||||||
|
|
||||||
|
LeaderboardEntry:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
user_id:
|
||||||
|
type: string
|
||||||
|
value:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
required:
|
||||||
|
- user_id
|
||||||
|
- value
|
||||||
|
|
||||||
|
LeaderboardData:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
all:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/LeaderboardEntry'
|
||||||
|
year:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/LeaderboardEntry'
|
||||||
|
month:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/LeaderboardEntry'
|
||||||
|
week:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/LeaderboardEntry'
|
||||||
|
required:
|
||||||
|
- all
|
||||||
|
- year
|
||||||
|
- month
|
||||||
|
- week
|
||||||
|
|
||||||
|
UserStatisticsResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
wpm:
|
||||||
|
$ref: '#/components/schemas/LeaderboardData'
|
||||||
|
duration:
|
||||||
|
$ref: '#/components/schemas/LeaderboardData'
|
||||||
|
words:
|
||||||
|
$ref: '#/components/schemas/LeaderboardData'
|
||||||
|
user:
|
||||||
|
$ref: '#/components/schemas/UserData'
|
||||||
|
required:
|
||||||
|
- wpm
|
||||||
|
- duration
|
||||||
|
- words
|
||||||
|
- user
|
||||||
|
|
||||||
|
HomeResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
database_info:
|
||||||
|
$ref: '#/components/schemas/DatabaseInfo'
|
||||||
|
streaks:
|
||||||
|
$ref: '#/components/schemas/StreaksResponse'
|
||||||
|
graph_data:
|
||||||
|
$ref: '#/components/schemas/GraphDataResponse'
|
||||||
|
user_statistics:
|
||||||
|
$ref: '#/components/schemas/UserStatisticsResponse'
|
||||||
|
user:
|
||||||
|
$ref: '#/components/schemas/UserData'
|
||||||
|
required:
|
||||||
|
- database_info
|
||||||
|
- streaks
|
||||||
|
- graph_data
|
||||||
|
- user_statistics
|
||||||
|
- user
|
||||||
|
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
BearerAuth:
|
BearerAuth:
|
||||||
type: http
|
type: http
|
||||||
@@ -288,6 +532,50 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
post:
|
||||||
|
summary: Upload a new document
|
||||||
|
operationId: createDocument
|
||||||
|
tags:
|
||||||
|
- Documents
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
document_file:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
required:
|
||||||
|
- document_file
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Successful response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DocumentResponse'
|
||||||
|
400:
|
||||||
|
description: Bad request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
401:
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
500:
|
||||||
|
description: Internal server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
/documents/{id}:
|
/documents/{id}:
|
||||||
get:
|
get:
|
||||||
@@ -329,6 +617,51 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/progress:
|
||||||
|
get:
|
||||||
|
summary: List progress records
|
||||||
|
operationId: getProgressList
|
||||||
|
tags:
|
||||||
|
- Progress
|
||||||
|
parameters:
|
||||||
|
- name: page
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
default: 1
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
default: 15
|
||||||
|
- name: document
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Successful response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProgressListResponse'
|
||||||
|
401:
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
500:
|
||||||
|
description: Internal server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
/progress/{id}:
|
/progress/{id}:
|
||||||
get:
|
get:
|
||||||
summary: Get document progress
|
summary: Get document progress
|
||||||
@@ -524,3 +857,204 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/home:
|
||||||
|
get:
|
||||||
|
summary: Get home page data
|
||||||
|
operationId: getHome
|
||||||
|
tags:
|
||||||
|
- Home
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Successful response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HomeResponse'
|
||||||
|
401:
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
500:
|
||||||
|
description: Internal server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/home/streaks:
|
||||||
|
get:
|
||||||
|
summary: Get user streaks
|
||||||
|
operationId: getStreaks
|
||||||
|
tags:
|
||||||
|
- Home
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Successful response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/StreaksResponse'
|
||||||
|
401:
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
500:
|
||||||
|
description: Internal server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/home/graph:
|
||||||
|
get:
|
||||||
|
summary: Get daily read stats graph data
|
||||||
|
operationId: getGraphData
|
||||||
|
tags:
|
||||||
|
- Home
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Successful response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GraphDataResponse'
|
||||||
|
401:
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
500:
|
||||||
|
description: Internal server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/home/statistics:
|
||||||
|
get:
|
||||||
|
summary: Get user statistics (leaderboards)
|
||||||
|
operationId: getUserStatistics
|
||||||
|
tags:
|
||||||
|
- Home
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Successful response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UserStatisticsResponse'
|
||||||
|
401:
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
500:
|
||||||
|
description: Internal server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/search:
|
||||||
|
get:
|
||||||
|
summary: Search external book sources
|
||||||
|
operationId: getSearch
|
||||||
|
tags:
|
||||||
|
- Search
|
||||||
|
parameters:
|
||||||
|
- name: query
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: source
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [LibGen, Annas Archive]
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Successful response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SearchResponse'
|
||||||
|
400:
|
||||||
|
description: Invalid query
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
401:
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
500:
|
||||||
|
description: Search error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
post:
|
||||||
|
summary: Download search result
|
||||||
|
operationId: postSearch
|
||||||
|
tags:
|
||||||
|
- Search
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/x-www-form-urlencoded:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
source:
|
||||||
|
type: string
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
author:
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- source
|
||||||
|
- title
|
||||||
|
- author
|
||||||
|
- id
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Download initiated
|
||||||
|
401:
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
500:
|
||||||
|
description: Download error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
@@ -2,10 +2,85 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"math"
|
||||||
|
|
||||||
"reichard.io/antholume/database"
|
"reichard.io/antholume/database"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GET /progress
|
||||||
|
func (s *Server) GetProgressList(ctx context.Context, request GetProgressListRequestObject) (GetProgressListResponseObject, error) {
|
||||||
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return GetProgressList401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
page := int64(1)
|
||||||
|
if request.Params.Page != nil {
|
||||||
|
page = *request.Params.Page
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := int64(15)
|
||||||
|
if request.Params.Limit != nil {
|
||||||
|
limit = *request.Params.Limit
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := database.GetProgressParams{
|
||||||
|
UserID: auth.UserName,
|
||||||
|
Offset: (page - 1) * limit,
|
||||||
|
Limit: limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.Params.Document != nil && *request.Params.Document != "" {
|
||||||
|
filter.DocFilter = true
|
||||||
|
filter.DocumentID = *request.Params.Document
|
||||||
|
}
|
||||||
|
|
||||||
|
progress, err := s.db.Queries.GetProgress(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetProgress DB Error:", err)
|
||||||
|
return GetProgressList500JSONResponse{Code: 500, Message: "Database error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
total := int64(len(progress))
|
||||||
|
var nextPage *int64
|
||||||
|
var previousPage *int64
|
||||||
|
|
||||||
|
// Calculate total pages
|
||||||
|
totalPages := int64(math.Ceil(float64(total) / float64(limit)))
|
||||||
|
if page < totalPages {
|
||||||
|
nextPage = ptrOf(page + 1)
|
||||||
|
}
|
||||||
|
if page > 1 {
|
||||||
|
previousPage = ptrOf(page - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiProgress := make([]Progress, len(progress))
|
||||||
|
for i, row := range progress {
|
||||||
|
apiProgress[i] = Progress{
|
||||||
|
Title: row.Title,
|
||||||
|
Author: row.Author,
|
||||||
|
DeviceName: &row.DeviceName,
|
||||||
|
Percentage: &row.Percentage,
|
||||||
|
DocumentId: &row.DocumentID,
|
||||||
|
UserId: &row.UserID,
|
||||||
|
CreatedAt: parseTimePtr(row.CreatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ProgressListResponse{
|
||||||
|
Progress: &apiProgress,
|
||||||
|
User: &UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
||||||
|
Page: &page,
|
||||||
|
Limit: &limit,
|
||||||
|
NextPage: nextPage,
|
||||||
|
PreviousPage: previousPage,
|
||||||
|
Total: &total,
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetProgressList200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
// GET /progress/{id}
|
// GET /progress/{id}
|
||||||
func (s *Server) GetProgress(ctx context.Context, request GetProgressRequestObject) (GetProgressResponseObject, error) {
|
func (s *Server) GetProgress(ctx context.Context, request GetProgressRequestObject) (GetProgressResponseObject, error) {
|
||||||
auth, ok := s.getSessionFromContext(ctx)
|
auth, ok := s.getSessionFromContext(ctx)
|
||||||
@@ -13,26 +88,39 @@ func (s *Server) GetProgress(ctx context.Context, request GetProgressRequestObje
|
|||||||
return GetProgress401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
return GetProgress401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.Id == "" {
|
filter := database.GetProgressParams{
|
||||||
return GetProgress404JSONResponse{Code: 404, Message: "Document ID required"}, nil
|
UserID: auth.UserName,
|
||||||
|
DocFilter: true,
|
||||||
|
DocumentID: request.Id,
|
||||||
|
Offset: 0,
|
||||||
|
Limit: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
|
progress, err := s.db.Queries.GetProgress(ctx, filter)
|
||||||
UserID: auth.UserName,
|
|
||||||
DocumentID: request.Id,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Error("GetProgress DB Error:", err)
|
||||||
return GetProgress404JSONResponse{Code: 404, Message: "Progress not found"}, nil
|
return GetProgress404JSONResponse{Code: 404, Message: "Progress not found"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
response := Progress{
|
if len(progress) == 0 {
|
||||||
UserId: progressRow.UserID,
|
return GetProgress404JSONResponse{Code: 404, Message: "Progress not found"}, nil
|
||||||
DocumentId: progressRow.DocumentID,
|
|
||||||
DeviceId: progressRow.DeviceID,
|
|
||||||
Percentage: progressRow.Percentage,
|
|
||||||
Progress: progressRow.Progress,
|
|
||||||
CreatedAt: parseTime(progressRow.CreatedAt),
|
|
||||||
}
|
|
||||||
return GetProgress200JSONResponse(response), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
row := progress[0]
|
||||||
|
apiProgress := Progress{
|
||||||
|
Title: row.Title,
|
||||||
|
Author: row.Author,
|
||||||
|
DeviceName: &row.DeviceName,
|
||||||
|
Percentage: &row.Percentage,
|
||||||
|
DocumentId: &row.DocumentID,
|
||||||
|
UserId: &row.UserID,
|
||||||
|
CreatedAt: parseTimePtr(row.CreatedAt),
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ProgressResponse{
|
||||||
|
Progress: &apiProgress,
|
||||||
|
User: &UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetProgress200JSONResponse(response), nil
|
||||||
|
}
|
||||||
59
api/v1/search.go
Normal file
59
api/v1/search.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"reichard.io/antholume/search"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /search
|
||||||
|
func (s *Server) GetSearch(ctx context.Context, request GetSearchRequestObject) (GetSearchResponseObject, error) {
|
||||||
|
|
||||||
|
if request.Params.Query == "" {
|
||||||
|
return GetSearch400JSONResponse{Code: 400, Message: "Invalid query"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
query := request.Params.Query
|
||||||
|
source := string(request.Params.Source)
|
||||||
|
|
||||||
|
// Validate source
|
||||||
|
if source != "LibGen" && source != "Annas Archive" {
|
||||||
|
return GetSearch400JSONResponse{Code: 400, Message: "Invalid source"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
searchResults, err := search.SearchBook(query, search.Source(source))
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Search Error:", err)
|
||||||
|
return GetSearch500JSONResponse{Code: 500, Message: "Search error"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
apiResults := make([]SearchItem, len(searchResults))
|
||||||
|
for i, item := range searchResults {
|
||||||
|
apiResults[i] = SearchItem{
|
||||||
|
Id: ptrOf(item.ID),
|
||||||
|
Title: ptrOf(item.Title),
|
||||||
|
Author: ptrOf(item.Author),
|
||||||
|
Language: ptrOf(item.Language),
|
||||||
|
Series: ptrOf(item.Series),
|
||||||
|
FileType: ptrOf(item.FileType),
|
||||||
|
FileSize: ptrOf(item.FileSize),
|
||||||
|
UploadDate: ptrOf(item.UploadDate),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response := SearchResponse{
|
||||||
|
Results: apiResults,
|
||||||
|
Source: source,
|
||||||
|
Query: query,
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetSearch200JSONResponse(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /search
|
||||||
|
func (s *Server) PostSearch(ctx context.Context, request PostSearchRequestObject) (PostSearchResponseObject, error) {
|
||||||
|
// This endpoint is used by the SSR template to queue a download
|
||||||
|
// For the API, we just return success - the actual download happens via /documents POST
|
||||||
|
return PostSearch200Response{}, nil
|
||||||
|
}
|
||||||
@@ -16,10 +16,25 @@ func (s *Server) GetSettings(ctx context.Context, request GetSettingsRequestObje
|
|||||||
return GetSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
|
return GetSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
devices, err := s.db.Queries.GetDevices(ctx, auth.UserName)
|
||||||
|
if err != nil {
|
||||||
|
return GetSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
apiDevices := make([]Device, len(devices))
|
||||||
|
for i, device := range devices {
|
||||||
|
apiDevices[i] = Device{
|
||||||
|
Id: &device.ID,
|
||||||
|
DeviceName: &device.DeviceName,
|
||||||
|
CreatedAt: parseTimePtr(device.CreatedAt),
|
||||||
|
LastSynced: parseTimePtr(device.LastSynced),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
response := SettingsResponse{
|
response := SettingsResponse{
|
||||||
Settings: []Setting{},
|
|
||||||
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
|
||||||
Timezone: user.Timezone,
|
Timezone: user.Timezone,
|
||||||
|
Devices: &apiDevices,
|
||||||
}
|
}
|
||||||
return GetSettings200JSONResponse(response), nil
|
return GetSettings200JSONResponse(response), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,3 +67,18 @@ func parseTime(s string) time.Time {
|
|||||||
}
|
}
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseTimePtr parses an interface{} (from SQL) to *time.Time
|
||||||
|
func parseTimePtr(v interface{}) *time.Time {
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
t := parseTime(s)
|
||||||
|
if t.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
1
frontend/.gitignore
vendored
Normal file
1
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
111
frontend/README.md
Normal file
111
frontend/README.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# AnthoLume Frontend
|
||||||
|
|
||||||
|
A React + TypeScript frontend for AnthoLume, replacing the server-side rendering (SSR) templates.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **React 19** - UI framework
|
||||||
|
- **TypeScript** - Type safety
|
||||||
|
- **React Query (TanStack Query)** - Server state management
|
||||||
|
- **Orval** - API client generation from OpenAPI spec
|
||||||
|
- **React Router** - Navigation
|
||||||
|
- **Tailwind CSS** - Styling
|
||||||
|
- **Vite** - Build tool
|
||||||
|
- **Axios** - HTTP client with auth interceptors
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
The frontend includes a complete authentication system:
|
||||||
|
|
||||||
|
### Auth Context
|
||||||
|
- `AuthProvider` - Manages authentication state globally
|
||||||
|
- `useAuth()` - Hook to access auth state and methods
|
||||||
|
- Token stored in `localStorage`
|
||||||
|
- Axios interceptors automatically attach Bearer token to API requests
|
||||||
|
|
||||||
|
### Protected Routes
|
||||||
|
- All main routes are wrapped in `ProtectedRoute`
|
||||||
|
- Unauthenticated users are redirected to `/login`
|
||||||
|
- Layout redirects to login if not authenticated
|
||||||
|
|
||||||
|
### Login Flow
|
||||||
|
1. User enters credentials on `/login`
|
||||||
|
2. POST to `/api/v1/auth/login`
|
||||||
|
3. Token stored in localStorage
|
||||||
|
4. Redirect to home page
|
||||||
|
5. Axios interceptor includes token in subsequent requests
|
||||||
|
|
||||||
|
### Logout Flow
|
||||||
|
1. User clicks "Logout" in dropdown menu
|
||||||
|
2. POST to `/api/v1/auth/logout`
|
||||||
|
3. Token cleared from localStorage
|
||||||
|
4. Redirect to `/login`
|
||||||
|
|
||||||
|
### 401 Handling
|
||||||
|
- Axios response interceptor clears token on 401 errors
|
||||||
|
- Prevents stale auth state
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The frontend mirrors the existing SSR templates structure:
|
||||||
|
|
||||||
|
### Pages
|
||||||
|
- `HomePage` - Landing page with recent documents
|
||||||
|
- `DocumentsPage` - Document listing with search and pagination
|
||||||
|
- `DocumentPage` - Single document view with details
|
||||||
|
- `ProgressPage` - Reading progress table
|
||||||
|
- `ActivityPage` - User activity log
|
||||||
|
- `SearchPage` - Search interface
|
||||||
|
- `SettingsPage` - User settings
|
||||||
|
- `LoginPage` - Authentication
|
||||||
|
|
||||||
|
### Components
|
||||||
|
- `Layout` - Main layout with navigation sidebar and header
|
||||||
|
- Generated API hooks from `api/v1/openapi.yaml`
|
||||||
|
|
||||||
|
## API Integration
|
||||||
|
|
||||||
|
The frontend uses **Orval** to generate TypeScript types and React Query hooks from the OpenAPI spec:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run generate:api
|
||||||
|
```
|
||||||
|
|
||||||
|
This generates:
|
||||||
|
- Type definitions for all API schemas
|
||||||
|
- React Query hooks (`useGetDocuments`, `useGetDocument`, etc.)
|
||||||
|
- Mutation hooks (`useLogin`, `useLogout`)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Generate API types (if OpenAPI spec changes)
|
||||||
|
npm run generate:api
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
The built output is in `dist/` and can be served by the Go backend or deployed separately.
|
||||||
|
|
||||||
|
## Migration from SSR
|
||||||
|
|
||||||
|
The frontend replicates the functionality of the following SSR templates:
|
||||||
|
- `templates/pages/home.tmpl` → `HomePage.tsx`
|
||||||
|
- `templates/pages/documents.tmpl` → `DocumentsPage.tsx`
|
||||||
|
- `templates/pages/document.tmpl` → `DocumentPage.tsx`
|
||||||
|
- `templates/pages/progress.tmpl` → `ProgressPage.tsx`
|
||||||
|
- `templates/pages/activity.tmpl` → `ActivityPage.tsx`
|
||||||
|
- `templates/pages/search.tmpl` → `SearchPage.tsx`
|
||||||
|
- `templates/pages/settings.tmpl` → `SettingsPage.tsx`
|
||||||
|
- `templates/pages/login.tmpl` → `LoginPage.tsx`
|
||||||
|
|
||||||
|
The styling follows the same Tailwind CSS classes as the original templates for consistency.
|
||||||
1
frontend/dist/assets/index-C8sHRJp6.css
vendored
Normal file
1
frontend/dist/assets/index-C8sHRJp6.css
vendored
Normal file
File diff suppressed because one or more lines are too long
65
frontend/dist/assets/index-DiNL9yHX.js
vendored
Normal file
65
frontend/dist/assets/index-DiNL9yHX.js
vendored
Normal file
File diff suppressed because one or more lines are too long
32
frontend/dist/index.html
vendored
Normal file
32
frontend/dist/index.html
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta
|
||||||
|
name="apple-mobile-web-app-status-bar-style"
|
||||||
|
content="black-translucent"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="theme-color"
|
||||||
|
content="#F3F4F6"
|
||||||
|
media="(prefers-color-scheme: light)"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="theme-color"
|
||||||
|
content="#1F2937"
|
||||||
|
media="(prefers-color-scheme: dark)"
|
||||||
|
/>
|
||||||
|
<title>AnthoLume</title>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<script type="module" crossorigin src="/assets/index-DiNL9yHX.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-C8sHRJp6.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
frontend/index.html
Normal file
31
frontend/index.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta
|
||||||
|
name="apple-mobile-web-app-status-bar-style"
|
||||||
|
content="black-translucent"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="theme-color"
|
||||||
|
content="#F3F4F6"
|
||||||
|
media="(prefers-color-scheme: light)"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="theme-color"
|
||||||
|
content="#1F2937"
|
||||||
|
media="(prefers-color-scheme: dark)"
|
||||||
|
/>
|
||||||
|
<title>AnthoLume</title>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
frontend/orval.config.ts
Normal file
21
frontend/orval.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'orval';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
antholume: {
|
||||||
|
output: {
|
||||||
|
mode: 'split',
|
||||||
|
baseUrl: '/api/v1',
|
||||||
|
target: 'src/generated',
|
||||||
|
schemas: 'src/generated/model',
|
||||||
|
client: 'react-query',
|
||||||
|
mock: false,
|
||||||
|
override: {
|
||||||
|
useQuery: true,
|
||||||
|
mutations: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
target: '../api/v1/openapi.yaml',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
6945
frontend/package-lock.json
generated
Normal file
6945
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "antholume-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"generate:api": "orval"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.62.16",
|
||||||
|
"axios": "^1.13.6",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.8",
|
||||||
|
"@types/react-dom": "^19.0.8",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"orval": "^7.5.0",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"vite": "^6.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
12
frontend/src/App.tsx
Normal file
12
frontend/src/App.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { AuthProvider } from './auth/AuthContext';
|
||||||
|
import { Routes } from './Routes';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
77
frontend/src/Routes.tsx
Normal file
77
frontend/src/Routes.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Route, Routes as ReactRoutes } from 'react-router-dom';
|
||||||
|
import Layout from './components/Layout';
|
||||||
|
import HomePage from './pages/HomePage';
|
||||||
|
import DocumentsPage from './pages/DocumentsPage';
|
||||||
|
import DocumentPage from './pages/DocumentPage';
|
||||||
|
import ProgressPage from './pages/ProgressPage';
|
||||||
|
import ActivityPage from './pages/ActivityPage';
|
||||||
|
import SearchPage from './pages/SearchPage';
|
||||||
|
import SettingsPage from './pages/SettingsPage';
|
||||||
|
import LoginPage from './pages/LoginPage';
|
||||||
|
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||||
|
|
||||||
|
export function Routes() {
|
||||||
|
return (
|
||||||
|
<ReactRoutes>
|
||||||
|
<Route path="/" element={<Layout />}>
|
||||||
|
<Route
|
||||||
|
index
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<HomePage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="documents"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DocumentsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="documents/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DocumentPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="progress"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ProgressPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="activity"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ActivityPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="search"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<SearchPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="settings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<SettingsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
</ReactRoutes>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
frontend/src/auth/AuthContext.tsx
Normal file
107
frontend/src/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useLogin, useLogout, useGetMe } from '../generated/anthoLumeAPIV1';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
user: { username: string; is_admin: boolean } | null;
|
||||||
|
token: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType extends AuthState {
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'antholume_token';
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [authState, setAuthState] = useState<AuthState>({
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginMutation = useLogin();
|
||||||
|
const logoutMutation = useLogout();
|
||||||
|
const { data: meData } = useGetMe(authState.isAuthenticated ? {} : undefined);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Check for existing token on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
if (token) {
|
||||||
|
setAuthState((prev) => ({ ...prev, token, isAuthenticated: true }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch user data when authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
if (meData?.data && authState.isAuthenticated) {
|
||||||
|
setAuthState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
user: meData.data,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [meData, authState.isAuthenticated]);
|
||||||
|
|
||||||
|
const login = async (username: string, password: string) => {
|
||||||
|
try {
|
||||||
|
loginMutation.mutate({
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
onSuccess: () => {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY) || 'authenticated';
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
|
||||||
|
setAuthState({
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: null,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
navigate('/');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
throw new Error('Login failed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
logoutMutation.mutate(undefined, {
|
||||||
|
onSuccess: () => {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
setAuthState({
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
});
|
||||||
|
navigate('/login');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ ...authState, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
18
frontend/src/auth/ProtectedRoute.tsx
Normal file
18
frontend/src/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
// Redirect to login with the current location saved
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
35
frontend/src/auth/authInterceptor.ts
Normal file
35
frontend/src/auth/authInterceptor.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'antholume_token';
|
||||||
|
|
||||||
|
// Request interceptor to add auth token to requests
|
||||||
|
axios.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
if (token && config.headers) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor to handle auth errors
|
||||||
|
axios.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
// Clear token on auth failure
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
// Optionally redirect to login
|
||||||
|
// window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default axios;
|
||||||
127
frontend/src/components/Layout.tsx
Normal file
127
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { Link, useLocation, Outlet, Navigate } from 'react-router-dom';
|
||||||
|
import { useGetMe } from '../generated/anthoLumeAPIV1';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ path: '/', label: 'Home', icon: 'home' },
|
||||||
|
{ path: '/documents', label: 'Documents', icon: 'documents' },
|
||||||
|
{ path: '/progress', label: 'Progress', icon: 'activity' },
|
||||||
|
{ path: '/activity', label: 'Activity', icon: 'activity' },
|
||||||
|
{ path: '/search', label: 'Search', icon: 'search' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const location = useLocation();
|
||||||
|
const { isAuthenticated, user, logout } = useAuth();
|
||||||
|
const { data } = useGetMe(isAuthenticated ? {} : undefined);
|
||||||
|
const userData = data?.data || user;
|
||||||
|
|
||||||
|
// Redirect to login if not authenticated
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-100 dark:bg-gray-800 min-h-screen">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between w-full h-16">
|
||||||
|
{/* Mobile Navigation Button */}
|
||||||
|
<div className="flex flex-col z-40 relative ml-6">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="absolute lg:hidden z-50 -top-2 w-7 h-7 flex cursor-pointer opacity-0"
|
||||||
|
id="mobile-nav-toggle"
|
||||||
|
/>
|
||||||
|
<span className="lg:hidden bg-black w-7 h-0.5 z-40 mt-0.5 dark:bg-white"></span>
|
||||||
|
<span className="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span>
|
||||||
|
<span className="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span>
|
||||||
|
<div
|
||||||
|
id="menu"
|
||||||
|
className="fixed -ml-6 h-full w-56 lg:w-48 bg-white dark:bg-gray-700 shadow-lg"
|
||||||
|
>
|
||||||
|
<div className="h-16 flex justify-end lg:justify-around">
|
||||||
|
<p className="text-xl font-bold dark:text-white text-right my-auto pr-8 lg:pr-0">
|
||||||
|
AnthoLume
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<nav className="flex flex-col">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={`flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 ${
|
||||||
|
location.pathname === item.path
|
||||||
|
? 'border-purple-500 dark:text-white'
|
||||||
|
: 'border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="mx-4 text-sm font-normal">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<a
|
||||||
|
className="flex flex-col gap-2 justify-center items-center p-6 w-full absolute bottom-0 text-black dark:text-white"
|
||||||
|
target="_blank"
|
||||||
|
href="https://gitea.va.reichard.io/evan/AnthoLume"
|
||||||
|
>
|
||||||
|
<span className="text-xs">v1.0.0</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header Title */}
|
||||||
|
<h1 className="text-xl font-bold dark:text-white px-6 lg:ml-44">
|
||||||
|
<Link to="/documents">Documents</Link>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* User Dropdown */}
|
||||||
|
<div className="relative flex items-center justify-end w-full p-4 space-x-4">
|
||||||
|
<input type="checkbox" id="user-dropdown-button" className="hidden" />
|
||||||
|
<div
|
||||||
|
id="user-dropdown"
|
||||||
|
className="transition duration-200 z-20 absolute right-4 top-16 pt-4"
|
||||||
|
>
|
||||||
|
<div className="w-40 origin-top-right bg-white rounded-md shadow-lg dark:shadow-gray-800 dark:bg-gray-700 ring-1 ring-black ring-opacity-5">
|
||||||
|
<div className="py-1">
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 w-full text-left"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label htmlFor="user-dropdown-button">
|
||||||
|
<div className="flex items-center gap-2 text-gray-500 dark:text-white text-md py-4 cursor-pointer">
|
||||||
|
<span>{userData?.username || 'User'}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="relative overflow-hidden">
|
||||||
|
<div id="container" className="h-[100dvh] px-4 overflow-auto md:px-6 lg:ml-48">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1414
frontend/src/generated/anthoLumeAPIV1.ts
Normal file
1414
frontend/src/generated/anthoLumeAPIV1.ts
Normal file
File diff suppressed because it is too large
Load Diff
15
frontend/src/generated/model/activity.ts
Normal file
15
frontend/src/generated/model/activity.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Activity {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
document_id: string;
|
||||||
|
activity_type: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
14
frontend/src/generated/model/activityResponse.ts
Normal file
14
frontend/src/generated/model/activityResponse.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { Activity } from './activity';
|
||||||
|
import type { UserData } from './userData';
|
||||||
|
|
||||||
|
export interface ActivityResponse {
|
||||||
|
activities: Activity[];
|
||||||
|
user: UserData;
|
||||||
|
}
|
||||||
11
frontend/src/generated/model/createDocumentBody.ts
Normal file
11
frontend/src/generated/model/createDocumentBody.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CreateDocumentBody = {
|
||||||
|
document_file: Blob;
|
||||||
|
};
|
||||||
14
frontend/src/generated/model/databaseInfo.ts
Normal file
14
frontend/src/generated/model/databaseInfo.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DatabaseInfo {
|
||||||
|
documents_size: number;
|
||||||
|
activity_size: number;
|
||||||
|
progress_size: number;
|
||||||
|
devices_size: number;
|
||||||
|
}
|
||||||
14
frontend/src/generated/model/device.ts
Normal file
14
frontend/src/generated/model/device.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Device {
|
||||||
|
id?: string;
|
||||||
|
device_name?: string;
|
||||||
|
created_at?: string;
|
||||||
|
last_synced?: string;
|
||||||
|
}
|
||||||
20
frontend/src/generated/model/document.ts
Normal file
20
frontend/src/generated/model/document.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Document {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
deleted: boolean;
|
||||||
|
words?: number;
|
||||||
|
filepath?: string;
|
||||||
|
percentage?: number;
|
||||||
|
total_time_seconds?: number;
|
||||||
|
}
|
||||||
16
frontend/src/generated/model/documentResponse.ts
Normal file
16
frontend/src/generated/model/documentResponse.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { Document } from './document';
|
||||||
|
import type { UserData } from './userData';
|
||||||
|
import type { Progress } from './progress';
|
||||||
|
|
||||||
|
export interface DocumentResponse {
|
||||||
|
document: Document;
|
||||||
|
user: UserData;
|
||||||
|
progress?: Progress;
|
||||||
|
}
|
||||||
22
frontend/src/generated/model/documentsResponse.ts
Normal file
22
frontend/src/generated/model/documentsResponse.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { Document } from './document';
|
||||||
|
import type { UserData } from './userData';
|
||||||
|
import type { WordCount } from './wordCount';
|
||||||
|
|
||||||
|
export interface DocumentsResponse {
|
||||||
|
documents: Document[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
next_page?: number;
|
||||||
|
previous_page?: number;
|
||||||
|
search?: string;
|
||||||
|
user: UserData;
|
||||||
|
word_counts: WordCount[];
|
||||||
|
}
|
||||||
12
frontend/src/generated/model/errorResponse.ts
Normal file
12
frontend/src/generated/model/errorResponse.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ErrorResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
14
frontend/src/generated/model/getActivityParams.ts
Normal file
14
frontend/src/generated/model/getActivityParams.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GetActivityParams = {
|
||||||
|
doc_filter?: boolean;
|
||||||
|
document_id?: string;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
13
frontend/src/generated/model/getDocumentsParams.ts
Normal file
13
frontend/src/generated/model/getDocumentsParams.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GetDocumentsParams = {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
search?: string;
|
||||||
|
};
|
||||||
13
frontend/src/generated/model/getProgressListParams.ts
Normal file
13
frontend/src/generated/model/getProgressListParams.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GetProgressListParams = {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
document?: string;
|
||||||
|
};
|
||||||
13
frontend/src/generated/model/getSearchParams.ts
Normal file
13
frontend/src/generated/model/getSearchParams.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { GetSearchSource } from './getSearchSource';
|
||||||
|
|
||||||
|
export type GetSearchParams = {
|
||||||
|
query: string;
|
||||||
|
source: GetSearchSource;
|
||||||
|
};
|
||||||
16
frontend/src/generated/model/getSearchSource.ts
Normal file
16
frontend/src/generated/model/getSearchSource.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GetSearchSource = typeof GetSearchSource[keyof typeof GetSearchSource];
|
||||||
|
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||||
|
export const GetSearchSource = {
|
||||||
|
LibGen: 'LibGen',
|
||||||
|
Annas_Archive: 'Annas Archive',
|
||||||
|
} as const;
|
||||||
12
frontend/src/generated/model/graphDataPoint.ts
Normal file
12
frontend/src/generated/model/graphDataPoint.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface GraphDataPoint {
|
||||||
|
date: string;
|
||||||
|
minutes_read: number;
|
||||||
|
}
|
||||||
14
frontend/src/generated/model/graphDataResponse.ts
Normal file
14
frontend/src/generated/model/graphDataResponse.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { GraphDataPoint } from './graphDataPoint';
|
||||||
|
import type { UserData } from './userData';
|
||||||
|
|
||||||
|
export interface GraphDataResponse {
|
||||||
|
graph_data: GraphDataPoint[];
|
||||||
|
user: UserData;
|
||||||
|
}
|
||||||
20
frontend/src/generated/model/homeResponse.ts
Normal file
20
frontend/src/generated/model/homeResponse.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { DatabaseInfo } from './databaseInfo';
|
||||||
|
import type { StreaksResponse } from './streaksResponse';
|
||||||
|
import type { GraphDataResponse } from './graphDataResponse';
|
||||||
|
import type { UserStatisticsResponse } from './userStatisticsResponse';
|
||||||
|
import type { UserData } from './userData';
|
||||||
|
|
||||||
|
export interface HomeResponse {
|
||||||
|
database_info: DatabaseInfo;
|
||||||
|
streaks: StreaksResponse;
|
||||||
|
graph_data: GraphDataResponse;
|
||||||
|
user_statistics: UserStatisticsResponse;
|
||||||
|
user: UserData;
|
||||||
|
}
|
||||||
42
frontend/src/generated/model/index.ts
Normal file
42
frontend/src/generated/model/index.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './activity';
|
||||||
|
export * from './activityResponse';
|
||||||
|
export * from './createDocumentBody';
|
||||||
|
export * from './databaseInfo';
|
||||||
|
export * from './device';
|
||||||
|
export * from './document';
|
||||||
|
export * from './documentResponse';
|
||||||
|
export * from './documentsResponse';
|
||||||
|
export * from './errorResponse';
|
||||||
|
export * from './getActivityParams';
|
||||||
|
export * from './getDocumentsParams';
|
||||||
|
export * from './getProgressListParams';
|
||||||
|
export * from './getSearchParams';
|
||||||
|
export * from './getSearchSource';
|
||||||
|
export * from './graphDataPoint';
|
||||||
|
export * from './graphDataResponse';
|
||||||
|
export * from './homeResponse';
|
||||||
|
export * from './leaderboardData';
|
||||||
|
export * from './leaderboardEntry';
|
||||||
|
export * from './loginRequest';
|
||||||
|
export * from './loginResponse';
|
||||||
|
export * from './postSearchBody';
|
||||||
|
export * from './progress';
|
||||||
|
export * from './progressListResponse';
|
||||||
|
export * from './progressResponse';
|
||||||
|
export * from './searchItem';
|
||||||
|
export * from './searchResponse';
|
||||||
|
export * from './setting';
|
||||||
|
export * from './settingsResponse';
|
||||||
|
export * from './streaksResponse';
|
||||||
|
export * from './userData';
|
||||||
|
export * from './userStatisticsResponse';
|
||||||
|
export * from './userStreak';
|
||||||
|
export * from './wordCount';
|
||||||
15
frontend/src/generated/model/leaderboardData.ts
Normal file
15
frontend/src/generated/model/leaderboardData.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { LeaderboardEntry } from './leaderboardEntry';
|
||||||
|
|
||||||
|
export interface LeaderboardData {
|
||||||
|
all: LeaderboardEntry[];
|
||||||
|
year: LeaderboardEntry[];
|
||||||
|
month: LeaderboardEntry[];
|
||||||
|
week: LeaderboardEntry[];
|
||||||
|
}
|
||||||
12
frontend/src/generated/model/leaderboardEntry.ts
Normal file
12
frontend/src/generated/model/leaderboardEntry.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LeaderboardEntry {
|
||||||
|
user_id: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
12
frontend/src/generated/model/loginRequest.ts
Normal file
12
frontend/src/generated/model/loginRequest.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
12
frontend/src/generated/model/loginResponse.ts
Normal file
12
frontend/src/generated/model/loginResponse.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
username: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
}
|
||||||
14
frontend/src/generated/model/postSearchBody.ts
Normal file
14
frontend/src/generated/model/postSearchBody.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PostSearchBody = {
|
||||||
|
source: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
17
frontend/src/generated/model/progress.ts
Normal file
17
frontend/src/generated/model/progress.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Progress {
|
||||||
|
title?: string;
|
||||||
|
author?: string;
|
||||||
|
device_name?: string;
|
||||||
|
percentage?: number;
|
||||||
|
document_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
19
frontend/src/generated/model/progressListResponse.ts
Normal file
19
frontend/src/generated/model/progressListResponse.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { Progress } from './progress';
|
||||||
|
import type { UserData } from './userData';
|
||||||
|
|
||||||
|
export interface ProgressListResponse {
|
||||||
|
progress?: Progress[];
|
||||||
|
user?: UserData;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
next_page?: number;
|
||||||
|
previous_page?: number;
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
14
frontend/src/generated/model/progressResponse.ts
Normal file
14
frontend/src/generated/model/progressResponse.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { Progress } from './progress';
|
||||||
|
import type { UserData } from './userData';
|
||||||
|
|
||||||
|
export interface ProgressResponse {
|
||||||
|
progress?: Progress;
|
||||||
|
user?: UserData;
|
||||||
|
}
|
||||||
18
frontend/src/generated/model/searchItem.ts
Normal file
18
frontend/src/generated/model/searchItem.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SearchItem {
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
author?: string;
|
||||||
|
language?: string;
|
||||||
|
series?: string;
|
||||||
|
file_type?: string;
|
||||||
|
file_size?: string;
|
||||||
|
upload_date?: string;
|
||||||
|
}
|
||||||
14
frontend/src/generated/model/searchResponse.ts
Normal file
14
frontend/src/generated/model/searchResponse.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { SearchItem } from './searchItem';
|
||||||
|
|
||||||
|
export interface SearchResponse {
|
||||||
|
results: SearchItem[];
|
||||||
|
source: string;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
14
frontend/src/generated/model/setting.ts
Normal file
14
frontend/src/generated/model/setting.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Setting {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
15
frontend/src/generated/model/settingsResponse.ts
Normal file
15
frontend/src/generated/model/settingsResponse.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { UserData } from './userData';
|
||||||
|
import type { Device } from './device';
|
||||||
|
|
||||||
|
export interface SettingsResponse {
|
||||||
|
user: UserData;
|
||||||
|
timezone?: string;
|
||||||
|
devices?: Device[];
|
||||||
|
}
|
||||||
14
frontend/src/generated/model/streaksResponse.ts
Normal file
14
frontend/src/generated/model/streaksResponse.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { UserStreak } from './userStreak';
|
||||||
|
import type { UserData } from './userData';
|
||||||
|
|
||||||
|
export interface StreaksResponse {
|
||||||
|
streaks: UserStreak[];
|
||||||
|
user: UserData;
|
||||||
|
}
|
||||||
12
frontend/src/generated/model/userData.ts
Normal file
12
frontend/src/generated/model/userData.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UserData {
|
||||||
|
username: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
}
|
||||||
16
frontend/src/generated/model/userStatisticsResponse.ts
Normal file
16
frontend/src/generated/model/userStatisticsResponse.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { LeaderboardData } from './leaderboardData';
|
||||||
|
import type { UserData } from './userData';
|
||||||
|
|
||||||
|
export interface UserStatisticsResponse {
|
||||||
|
wpm: LeaderboardData;
|
||||||
|
duration: LeaderboardData;
|
||||||
|
words: LeaderboardData;
|
||||||
|
user: UserData;
|
||||||
|
}
|
||||||
17
frontend/src/generated/model/userStreak.ts
Normal file
17
frontend/src/generated/model/userStreak.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UserStreak {
|
||||||
|
window: string;
|
||||||
|
max_streak: number;
|
||||||
|
max_streak_start_date: string;
|
||||||
|
max_streak_end_date: string;
|
||||||
|
current_streak: number;
|
||||||
|
current_streak_start_date: string;
|
||||||
|
current_streak_end_date: string;
|
||||||
|
}
|
||||||
12
frontend/src/generated/model/wordCount.ts
Normal file
12
frontend/src/generated/model/wordCount.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface WordCount {
|
||||||
|
document_id: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
46
frontend/src/index.css
Normal file
46
frontend/src/index.css
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* PWA Styling */
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
height: calc(100% + env(safe-area-inset-bottom));
|
||||||
|
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0
|
||||||
|
env(safe-area-inset-left);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
height: calc(100dvh - 4rem - env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
|
||||||
|
#container {
|
||||||
|
padding-bottom: calc(5em + env(safe-area-inset-bottom) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No Scrollbar - IE, Edge, Firefox */
|
||||||
|
* {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No Scrollbar - WebKit */
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button visibility toggle */
|
||||||
|
.css-button:checked + div {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.css-button + div {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
29
frontend/src/main.tsx
Normal file
29
frontend/src/main.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import './auth/authInterceptor';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
43
frontend/src/pages/ActivityPage.tsx
Normal file
43
frontend/src/pages/ActivityPage.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useGetActivity } from '../generated/anthoLumeAPIV1';
|
||||||
|
|
||||||
|
export default function ActivityPage() {
|
||||||
|
const { data, isLoading } = useGetActivity({ offset: 0, limit: 100 });
|
||||||
|
const activities = data?.data?.activities;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
||||||
|
<table className="min-w-full bg-white dark:bg-gray-700">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b dark:border-gray-600">
|
||||||
|
<th className="text-left p-3 text-gray-500 dark:text-white">Activity Type</th>
|
||||||
|
<th className="text-left p-3 text-gray-500 dark:text-white">Document</th>
|
||||||
|
<th className="text-left p-3 text-gray-500 dark:text-white">Timestamp</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{activities?.map((activity: any) => (
|
||||||
|
<tr key={activity.id} className="border-b dark:border-gray-600">
|
||||||
|
<td className="p-3 text-gray-700 dark:text-gray-300">
|
||||||
|
{activity.activity_type}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<a href={`/documents/${activity.document_id}`} className="text-blue-600 dark:text-blue-400">
|
||||||
|
{activity.document_id}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-gray-700 dark:text-gray-300">
|
||||||
|
{new Date(activity.timestamp).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
frontend/src/pages/DocumentPage.tsx
Normal file
111
frontend/src/pages/DocumentPage.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
|
||||||
|
|
||||||
|
export default function DocumentPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
||||||
|
const { data: docData, isLoading: docLoading } = useGetDocument(id || '');
|
||||||
|
|
||||||
|
const { data: progressData, isLoading: progressLoading } = useGetProgress(id || '');
|
||||||
|
|
||||||
|
if (docLoading || progressLoading) {
|
||||||
|
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = docData?.data?.document;
|
||||||
|
const progress = progressData?.data;
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return <div className="text-gray-500 dark:text-white">Document not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full relative">
|
||||||
|
<div
|
||||||
|
className="h-full w-full overflow-scroll bg-white shadow-lg dark:bg-gray-700 rounded dark:text-white p-4"
|
||||||
|
>
|
||||||
|
{/* Document Info */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-2 float-left w-44 md:w-60 lg:w-80 mr-4 mb-2 relative"
|
||||||
|
>
|
||||||
|
<div className="rounded object-fill w-full bg-gray-200 dark:bg-gray-600 h-60">
|
||||||
|
{/* Cover image placeholder */}
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||||
|
No Cover
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={`/reader#id=${document.id}&type=REMOTE`}
|
||||||
|
className="text-white bg-blue-700 hover:bg-blue-800 font-medium rounded text-sm text-center py-1 dark:bg-blue-600 dark:hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Read
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap-reverse justify-between gap-2">
|
||||||
|
<div className="min-w-[50%] md:mr-2">
|
||||||
|
<div className="flex gap-1 text-sm">
|
||||||
|
<p className="text-gray-500">Words:</p>
|
||||||
|
<p className="font-medium">{document.words || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document Details Grid */}
|
||||||
|
<div className="grid sm:grid-cols-2 justify-between gap-4 pb-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Title</p>
|
||||||
|
<p className="font-medium text-lg">{document.title}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Author</p>
|
||||||
|
<p className="font-medium text-lg">{document.author}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Time Read</p>
|
||||||
|
<p className="font-medium text-lg">
|
||||||
|
{progress?.progress?.percentage ? `${Math.round(progress.progress.percentage)}%` : '0%'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Progress</p>
|
||||||
|
<p className="font-medium text-lg">
|
||||||
|
{progress?.progress?.percentage ? `${Math.round(progress.progress.percentage)}%` : '0%'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="text-gray-500 inline-flex gap-2 relative">
|
||||||
|
<p>Description</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative font-medium text-justify hyphens-auto">
|
||||||
|
<p>N/A</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="mt-4 grid sm:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Words</p>
|
||||||
|
<p className="font-medium">{document.words || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Created</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{new Date(document.created_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Updated</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{new Date(document.updated_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
312
frontend/src/pages/DocumentsPage.tsx
Normal file
312
frontend/src/pages/DocumentsPage.tsx
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import { useState, FormEvent, useRef } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||||
|
|
||||||
|
interface DocumentCardProps {
|
||||||
|
doc: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
created_at: string;
|
||||||
|
deleted: boolean;
|
||||||
|
words?: number;
|
||||||
|
filepath?: string;
|
||||||
|
percentage?: number;
|
||||||
|
total_time_seconds?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity icon SVG
|
||||||
|
function ActivityIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-20 h-20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download icon SVG
|
||||||
|
function DownloadIcon({ disabled }: { disabled?: boolean }) {
|
||||||
|
if (disabled) {
|
||||||
|
return (
|
||||||
|
<svg className="w-20 h-20 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="21 15 16 10 8 10" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="21" />
|
||||||
|
<line x1="21" y1="15" x2="21" y2="15" opacity="0" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg className="w-20 h-20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="21 15 16 10 8 10" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="21" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentCard({ doc }: DocumentCardProps) {
|
||||||
|
const percentage = doc.percentage || 0;
|
||||||
|
const totalTimeSeconds = doc.total_time_seconds || 0;
|
||||||
|
|
||||||
|
// Convert seconds to nice format (e.g., "2h 30m")
|
||||||
|
const niceSeconds = (seconds: number): string => {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
return `${minutes}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full relative">
|
||||||
|
<div
|
||||||
|
className="flex gap-4 w-full h-full p-4 shadow-lg bg-white dark:bg-gray-700 rounded"
|
||||||
|
>
|
||||||
|
<div className="min-w-fit my-auto h-48 relative">
|
||||||
|
<Link to={`/documents/${doc.id}`}>
|
||||||
|
<img
|
||||||
|
className="rounded object-cover h-full"
|
||||||
|
src={`/api/v1/documents/${doc.id}/cover`}
|
||||||
|
alt={doc.title}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-around dark:text-white w-full text-sm">
|
||||||
|
<div className="inline-flex shrink-0 items-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-400">Title</p>
|
||||||
|
<p className="font-medium">{doc.title || "Unknown"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex shrink-0 items-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-400">Author</p>
|
||||||
|
<p className="font-medium">{doc.author || "Unknown"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex shrink-0 items-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-400">Progress</p>
|
||||||
|
<p className="font-medium">{percentage}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex shrink-0 items-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-400">Time Read</p>
|
||||||
|
<p className="font-medium">{niceSeconds(totalTimeSeconds)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="absolute flex flex-col gap-2 right-4 bottom-4 text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<Link to={`/activity?document=${doc.id}`}>
|
||||||
|
<ActivityIcon />
|
||||||
|
</Link>
|
||||||
|
{doc.filepath ? (
|
||||||
|
<Link to={`/documents/${doc.id}/file`}>
|
||||||
|
<DownloadIcon />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<DownloadIcon disabled />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search icon SVG
|
||||||
|
function SearchIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-15 h-15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="M21 21l-6-6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload icon SVG
|
||||||
|
function UploadIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-34 h-34" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="17 8 12 3 7 8" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocumentsPage() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [limit] = useState(9);
|
||||||
|
const [uploadMode, setUploadMode] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search });
|
||||||
|
const createMutation = useCreateDocument();
|
||||||
|
const docs = data?.data?.documents;
|
||||||
|
const previousPage = data?.data?.previous_page;
|
||||||
|
const nextPage = data?.data?.next_page;
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!file.name.endsWith('.epub')) {
|
||||||
|
alert('Please upload an EPUB file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createMutation.mutateAsync({
|
||||||
|
data: {
|
||||||
|
document_file: file,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
alert('Document uploaded successfully!');
|
||||||
|
setUploadMode(false);
|
||||||
|
refetch();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload failed:', error);
|
||||||
|
alert('Failed to upload document');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelUpload = () => {
|
||||||
|
setUploadMode(false);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Search Form */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-2 grow p-4 mb-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<form className="flex gap-4 flex-col lg:flex-row" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col w-full grow">
|
||||||
|
<div className="flex relative">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
||||||
|
>
|
||||||
|
<SearchIcon />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-2 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||||
|
placeholder="Search Author / Title"
|
||||||
|
name="search"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lg:w-60">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="font-medium px-4 py-2 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document Grid */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{docs?.map((doc: any) => (
|
||||||
|
<DocumentCard key={doc.id} doc={doc} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="w-full flex gap-4 justify-center mt-4 text-black dark:text-white">
|
||||||
|
{previousPage && previousPage > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
className="bg-white shadow-lg dark:bg-gray-600 hover:bg-gray-400 font-medium rounded text-sm text-center p-2 w-24 dark:hover:bg-gray-700 focus:outline-none"
|
||||||
|
>
|
||||||
|
◄
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{nextPage && nextPage > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
className="bg-white shadow-lg dark:bg-gray-600 hover:bg-gray-400 font-medium rounded text-sm text-center p-2 w-24 dark:hover:bg-gray-700 focus:outline-none"
|
||||||
|
>
|
||||||
|
►
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Button */}
|
||||||
|
<div
|
||||||
|
className="fixed bottom-6 right-6 rounded-full flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="upload-file-button"
|
||||||
|
className="hidden"
|
||||||
|
checked={uploadMode}
|
||||||
|
onChange={() => setUploadMode(!uploadMode)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`absolute right-0 z-10 bottom-0 rounded p-4 bg-gray-800 dark:bg-gray-200 text-white dark:text-black w-72 text-sm flex flex-col gap-2 ${uploadMode ? 'display-block' : 'display-none'}`}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
encType="multipart/form-data"
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".epub"
|
||||||
|
id="document_file"
|
||||||
|
name="document_file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="font-medium px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
|
type="submit"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleFileChange({ target: { files: fileInputRef.current?.files } } as any);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Upload File
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<label htmlFor="upload-file-button">
|
||||||
|
<div
|
||||||
|
className="w-full text-center cursor-pointer font-medium mt-2 px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
|
onClick={handleCancelUpload}
|
||||||
|
>
|
||||||
|
Cancel Upload
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
className="w-16 h-16 bg-gray-800 dark:bg-gray-200 rounded-full flex items-center justify-center opacity-30 hover:opacity-100 transition-all duration-200 cursor-pointer"
|
||||||
|
htmlFor="upload-file-button"
|
||||||
|
>
|
||||||
|
<UploadIcon />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
262
frontend/src/pages/HomePage.tsx
Normal file
262
frontend/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useGetHome, useGetDocuments } from '../generated/anthoLumeAPIV1';
|
||||||
|
import type { GraphDataPoint, LeaderboardData } from '../generated/model';
|
||||||
|
|
||||||
|
interface InfoCardProps {
|
||||||
|
title: string;
|
||||||
|
size: string | number;
|
||||||
|
link?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoCard({ title, size, link }: InfoCardProps) {
|
||||||
|
if (link) {
|
||||||
|
return (
|
||||||
|
<Link to={link} className="w-full">
|
||||||
|
<div className="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||||
|
<div className="flex flex-col justify-around dark:text-white w-full text-sm">
|
||||||
|
<p className="text-2xl font-bold text-black dark:text-white">{size}</p>
|
||||||
|
<p className="text-sm text-gray-400">{title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||||
|
<div className="flex flex-col justify-around dark:text-white w-full text-sm">
|
||||||
|
<p className="text-2xl font-bold text-black dark:text-white">{size}</p>
|
||||||
|
<p className="text-sm text-gray-400">{title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StreakCardProps {
|
||||||
|
window: 'DAY' | 'WEEK';
|
||||||
|
currentStreak: number;
|
||||||
|
currentStreakStartDate: string;
|
||||||
|
currentStreakEndDate: string;
|
||||||
|
maxStreak: number;
|
||||||
|
maxStreakStartDate: string;
|
||||||
|
maxStreakEndDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StreakCard({ window, currentStreak, currentStreakStartDate, currentStreakEndDate, maxStreak, maxStreakStartDate, maxStreakEndDate }: StreakCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||||
|
<p className="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500">
|
||||||
|
{window === 'WEEK' ? 'Weekly Read Streak' : 'Daily Read Streak'}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-end my-6 space-x-2">
|
||||||
|
<p className="text-5xl font-bold text-black dark:text-white">{currentStreak}</p>
|
||||||
|
</div>
|
||||||
|
<div className="dark:text-white">
|
||||||
|
<div className="flex items-center justify-between pb-2 mb-2 text-sm border-b border-gray-200">
|
||||||
|
<div>
|
||||||
|
<p>{window === 'WEEK' ? 'Current Weekly Streak' : 'Current Daily Streak'}</p>
|
||||||
|
<div className="flex items-end text-sm text-gray-400">
|
||||||
|
{currentStreakStartDate} ➞ {currentStreakEndDate}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end font-bold">{currentStreak}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between pb-2 mb-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<p>{window === 'WEEK' ? 'Best Weekly Streak' : 'Best Daily Streak'}</p>
|
||||||
|
<div className="flex items-end text-sm text-gray-400">
|
||||||
|
{maxStreakStartDate} ➞ {maxStreakEndDate}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end font-bold">{maxStreak}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeaderboardCardProps {
|
||||||
|
name: string;
|
||||||
|
data: LeaderboardData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex flex-col justify-between h-full w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p className="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500">
|
||||||
|
{name} Leaderboard
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 text-xs text-gray-400 items-center">
|
||||||
|
<span className="cursor-pointer hover:text-black dark:hover:text-white">all</span>
|
||||||
|
<span className="cursor-pointer hover:text-black dark:hover:text-white">year</span>
|
||||||
|
<span className="cursor-pointer hover:text-black dark:hover:text-white">month</span>
|
||||||
|
<span className="cursor-pointer hover:text-black dark:hover:text-white">week</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* All time data */}
|
||||||
|
<div className="flex items-end my-6 space-x-2">
|
||||||
|
{data.all.length === 0 ? (
|
||||||
|
<p className="text-5xl font-bold text-black dark:text-white">N/A</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-5xl font-bold text-black dark:text-white">{data.all[0]?.user_id || 'N/A'}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dark:text-white">
|
||||||
|
{data.all.slice(0, 3).map((item: any, index: number) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`flex items-center justify-between pt-2 pb-2 text-sm ${index > 0 ? 'border-t border-gray-200' : ''}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p>{item.user_id}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end font-bold">{item.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GraphVisualization({ data }: { data: GraphDataPoint[] }) {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="relative h-24 flex items-center justify-center bg-gray-100 dark:bg-gray-600">
|
||||||
|
<p className="text-gray-400 dark:text-gray-300">No data available</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple bar visualization (could be enhanced with SVG bezier curve like SSR)
|
||||||
|
const maxMinutes = Math.max(...data.map(d => d.minutes_read), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-24 flex items-end justify-between p-2 bg-gray-100 dark:bg-gray-600">
|
||||||
|
{data.map((point, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex-1 mx-0.5 bg-blue-500 hover:bg-blue-600 transition-colors relative group"
|
||||||
|
style={{ height: `${(point.minutes_read / maxMinutes) * 100}%` }}
|
||||||
|
>
|
||||||
|
<div className="absolute bottom-full mb-1 left-0 w-full text-xs text-center text-gray-600 dark:text-gray-300 opacity-0 group-hover:opacity-100 pointer-events-none">
|
||||||
|
{point.minutes_read} min
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const { data: homeData, isLoading: homeLoading } = useGetHome();
|
||||||
|
const { data: docsData, isLoading: docsLoading } = useGetDocuments({ page: 1, limit: 9 });
|
||||||
|
|
||||||
|
const docs = docsData?.data?.documents;
|
||||||
|
const dbInfo = homeData?.data?.database_info;
|
||||||
|
const streaks = homeData?.data?.streaks?.streaks;
|
||||||
|
const graphData = homeData?.data?.graph_data?.graph_data;
|
||||||
|
const userStats = homeData?.data?.user_statistics;
|
||||||
|
|
||||||
|
if (homeLoading || docsLoading) {
|
||||||
|
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Daily Read Totals Graph */}
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="relative w-full bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||||
|
<p className="absolute top-3 left-5 text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500">
|
||||||
|
Daily Read Totals
|
||||||
|
</p>
|
||||||
|
<GraphVisualization data={graphData || []} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Cards */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
<InfoCard
|
||||||
|
title="Documents"
|
||||||
|
size={dbInfo?.documents_size || 0}
|
||||||
|
link="./documents"
|
||||||
|
/>
|
||||||
|
<InfoCard
|
||||||
|
title="Activity Records"
|
||||||
|
size={dbInfo?.activity_size || 0}
|
||||||
|
link="./activity"
|
||||||
|
/>
|
||||||
|
<InfoCard
|
||||||
|
title="Progress Records"
|
||||||
|
size={dbInfo?.progress_size || 0}
|
||||||
|
link="./progress"
|
||||||
|
/>
|
||||||
|
<InfoCard
|
||||||
|
title="Devices"
|
||||||
|
size={dbInfo?.devices_size || 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Streak Cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
{streaks?.map((streak: any, index) => (
|
||||||
|
<StreakCard
|
||||||
|
key={index}
|
||||||
|
window={streak.window as 'DAY' | 'WEEK'}
|
||||||
|
currentStreak={streak.current_streak}
|
||||||
|
currentStreakStartDate={streak.current_streak_start_date}
|
||||||
|
currentStreakEndDate={streak.current_streak_end_date}
|
||||||
|
maxStreak={streak.max_streak}
|
||||||
|
maxStreakStartDate={streak.max_streak_start_date}
|
||||||
|
maxStreakEndDate={streak.max_streak_end_date}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Leaderboard Cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<LeaderboardCard
|
||||||
|
name="WPM"
|
||||||
|
data={userStats?.wpm || { all: [], year: [], month: [], week: []}}
|
||||||
|
/>
|
||||||
|
<LeaderboardCard
|
||||||
|
name="Duration"
|
||||||
|
data={userStats?.duration || { all: [], year: [], month: [], week: []}}
|
||||||
|
/>
|
||||||
|
<LeaderboardCard
|
||||||
|
name="Words"
|
||||||
|
data={userStats?.words || { all: [], year: [], month: [], week: []}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Documents */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{docs?.slice(0, 6).map((doc: any) => (
|
||||||
|
<div
|
||||||
|
key={doc.id}
|
||||||
|
className="flex flex-col gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<h3 className="font-medium text-lg">{doc.title}</h3>
|
||||||
|
<p className="text-sm">{doc.author}</p>
|
||||||
|
<Link
|
||||||
|
to={`/documents/${doc.id}`}
|
||||||
|
className="text-white bg-blue-700 hover:bg-blue-800 font-medium rounded text-sm text-center py-1 dark:bg-blue-600 dark:hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
View Document
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
frontend/src/pages/LoginPage.tsx
Normal file
87
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const { login } = useAuth();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Invalid credentials');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-100 dark:bg-gray-800 dark:text-white min-h-screen">
|
||||||
|
<div className="flex flex-wrap w-full">
|
||||||
|
<div className="flex flex-col w-full md:w-1/2">
|
||||||
|
<div
|
||||||
|
className="flex flex-col justify-center px-8 pt-8 my-auto md:justify-start md:pt-0 md:px-24 lg:px-32"
|
||||||
|
>
|
||||||
|
<p className="text-3xl text-center">Welcome.</p>
|
||||||
|
<form className="flex flex-col pt-3 md:pt-8" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col pt-4">
|
||||||
|
<div className="flex relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||||
|
placeholder="Username"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col pt-4 mb-12">
|
||||||
|
<div className="flex relative">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||||
|
placeholder="Password"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<span className="absolute -bottom-5 text-red-400 text-xs">{error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full px-4 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Logging in...' : 'Login'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div className="pt-12 pb-12 text-center">
|
||||||
|
<p className="mt-4">
|
||||||
|
<a href="/local" className="font-semibold underline">
|
||||||
|
Offline / Local Mode
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hidden image-fader w-1/2 shadow-2xl h-screen relative md:block">
|
||||||
|
<div className="w-full h-screen object-cover ease-in-out top-0 left-0 bg-gray-300 flex items-center justify-center">
|
||||||
|
<span className="text-gray-500">AnthoLume</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/pages/ProgressPage.tsx
Normal file
51
frontend/src/pages/ProgressPage.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useGetProgressList } from '../generated/anthoLumeAPIV1';
|
||||||
|
|
||||||
|
export default function ProgressPage() {
|
||||||
|
const { data, isLoading } = useGetProgressList({ page: 1, limit: 15 });
|
||||||
|
const progress = data?.data?.progress;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
||||||
|
<table className="min-w-full bg-white dark:bg-gray-700">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b dark:border-gray-600">
|
||||||
|
<th className="text-left p-3 text-gray-500 dark:text-white">Document</th>
|
||||||
|
<th className="text-left p-3 text-gray-500 dark:text-white">Device Name</th>
|
||||||
|
<th className="text-left p-3 text-gray-500 dark:text-white">Percentage</th>
|
||||||
|
<th className="text-left p-3 text-gray-500 dark:text-white">Created At</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{progress?.map((row: any) => (
|
||||||
|
<tr key={row.document_id} className="border-b dark:border-gray-600">
|
||||||
|
<td className="p-3">
|
||||||
|
<Link
|
||||||
|
to={`/documents/${row.document_id}`}
|
||||||
|
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
{row.author || 'Unknown'} - {row.title || 'Unknown'}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-gray-700 dark:text-gray-300">
|
||||||
|
{row.device_name || 'Unknown'}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-gray-700 dark:text-gray-300">
|
||||||
|
{row.percentage ? Math.round(row.percentage) : 0}%
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-gray-700 dark:text-gray-300">
|
||||||
|
{row.created_at ? new Date(row.created_at).toLocaleDateString() : 'N/A'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
frontend/src/pages/SearchPage.tsx
Normal file
183
frontend/src/pages/SearchPage.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useGetSearch } from '../generated/anthoLumeAPIV1';
|
||||||
|
import { GetSearchSource } from '../generated/model/getSearchSource';
|
||||||
|
|
||||||
|
// Search icon SVG
|
||||||
|
function SearchIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-15 h-15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="M21 21l-6-6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Documents icon SVG
|
||||||
|
function DocumentsIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-15 h-15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
<polyline points="10 9 9 9 8 9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download icon SVG
|
||||||
|
function DownloadIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-15 h-15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="21 15 16 10 8 10" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="21" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchPage() {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [source, setSource] = useState<GetSearchSource>(GetSearchSource.LibGen);
|
||||||
|
|
||||||
|
const { data, isLoading } = useGetSearch({ query, source });
|
||||||
|
const results = data?.data?.results;
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// Trigger refetch by updating query
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col md:flex-row gap-4">
|
||||||
|
<div className="flex flex-col gap-4 grow">
|
||||||
|
{/* Search Form */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<form className="flex gap-4 flex-col lg:flex-row" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col w-full grow">
|
||||||
|
<div className="flex relative">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
||||||
|
>
|
||||||
|
<SearchIcon />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||||
|
placeholder="Query"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex relative min-w-[12em]">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
||||||
|
>
|
||||||
|
<DocumentsIcon />
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={source}
|
||||||
|
onChange={(e) => setSource(e.target.value as GetSearchSource)}
|
||||||
|
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="LibGen">Library Genesis</option>
|
||||||
|
<option value="Annas Archive">Annas Archive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="lg:w-60">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="font-medium px-4 py-2 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Results Table */}
|
||||||
|
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
||||||
|
<table
|
||||||
|
className="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm md:text-sm"
|
||||||
|
>
|
||||||
|
<thead className="text-gray-800 dark:text-gray-400">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
className="w-12 p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
></th>
|
||||||
|
<th
|
||||||
|
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
Document
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
Series
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
Size
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="p-3 hidden md:block font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
Date
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-black dark:text-white">
|
||||||
|
{isLoading && (
|
||||||
|
<tr>
|
||||||
|
<td className="text-center p-3" colSpan={6}>Loading...</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{!isLoading && !results && (
|
||||||
|
<tr>
|
||||||
|
<td className="text-center p-3" colSpan={6}>No Results</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{!isLoading && results && results.map((item: any) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td
|
||||||
|
className="p-3 border-b border-gray-200 text-gray-500 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="hover:text-purple-600"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<DownloadIcon />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 border-b border-gray-200">
|
||||||
|
{item.author || 'N/A'} - {item.title || 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 border-b border-gray-200">
|
||||||
|
<p>{item.series || 'N/A'}</p>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 border-b border-gray-200">
|
||||||
|
<p>{item.file_type || 'N/A'}</p>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 border-b border-gray-200">
|
||||||
|
<p>{item.file_size || 'N/A'}</p>
|
||||||
|
</td>
|
||||||
|
<td className="hidden md:table-cell p-3 border-b border-gray-200">
|
||||||
|
<p>{item.upload_date || 'N/A'}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
frontend/src/pages/SettingsPage.tsx
Normal file
215
frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useGetSettings } from '../generated/anthoLumeAPIV1';
|
||||||
|
|
||||||
|
// User icon SVG
|
||||||
|
function UserIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-60 h-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="8" r="4" />
|
||||||
|
<path d="M12 12c-4 0-8 3-8 8h16c0-5-4-8-8-8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password icon SVG
|
||||||
|
function PasswordIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-15 h-15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clock icon SVG
|
||||||
|
function ClockIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-15 h-15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { data, isLoading } = useGetSettings();
|
||||||
|
const settingsData = data?.data;
|
||||||
|
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [timezone, setTimezone] = useState(settingsData?.timezone || '');
|
||||||
|
|
||||||
|
const handlePasswordSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// TODO: Call API to change password
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimezoneSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// TODO: Call API to change timezone
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col md:flex-row gap-4">
|
||||||
|
{/* User Profile Card */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="flex flex-col p-4 items-center rounded shadow-lg md:w-60 lg:w-80 bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<UserIcon />
|
||||||
|
<p className="text-lg">{settingsData?.user?.username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 grow">
|
||||||
|
{/* Change Password Form */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<p className="text-lg font-semibold mb-2">Change Password</p>
|
||||||
|
<form
|
||||||
|
className="flex gap-4 flex-col lg:flex-row"
|
||||||
|
onSubmit={handlePasswordSubmit}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col grow">
|
||||||
|
<div className="flex relative">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
||||||
|
>
|
||||||
|
<PasswordIcon />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||||
|
placeholder="Password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col grow">
|
||||||
|
<div className="flex relative">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
||||||
|
>
|
||||||
|
<PasswordIcon />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||||
|
placeholder="New Password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lg:w-60">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="font-medium px-4 py-2 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Change Timezone Form */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col grow gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<p className="text-lg font-semibold mb-2">Change Timezone</p>
|
||||||
|
<form
|
||||||
|
className="flex gap-4 flex-col lg:flex-row"
|
||||||
|
onSubmit={handleTimezoneSubmit}
|
||||||
|
>
|
||||||
|
<div className="flex relative grow">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
||||||
|
>
|
||||||
|
<ClockIcon />
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={timezone}
|
||||||
|
onChange={(e) => setTimezone(e.target.value)}
|
||||||
|
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="UTC">UTC</option>
|
||||||
|
<option value="America/New_York">America/New_York</option>
|
||||||
|
<option value="America/Chicago">America/Chicago</option>
|
||||||
|
<option value="America/Denver">America/Denver</option>
|
||||||
|
<option value="America/Los_Angeles">America/Los_Angeles</option>
|
||||||
|
<option value="Europe/London">Europe/London</option>
|
||||||
|
<option value="Europe/Paris">Europe/Paris</option>
|
||||||
|
<option value="Asia/Tokyo">Asia/Tokyo</option>
|
||||||
|
<option value="Asia/Shanghai">Asia/Shanghai</option>
|
||||||
|
<option value="Australia/Sydney">Australia/Sydney</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="lg:w-60">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="font-medium px-4 py-2 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Devices Table */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<p className="text-lg font-semibold">Devices</p>
|
||||||
|
<table className="min-w-full bg-white dark:bg-gray-700 text-sm">
|
||||||
|
<thead className="text-gray-800 dark:text-gray-400">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
className="p-3 pl-0 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
Last Sync
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
Created
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-black dark:text-white">
|
||||||
|
{!settingsData?.devices || settingsData.devices.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td className="text-center p-3" colSpan={3}>No Results</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
settingsData.devices.map((device: any) => (
|
||||||
|
<tr key={device.id}>
|
||||||
|
<td className="p-3 pl-0">
|
||||||
|
<p>{device.device_name || 'Unknown'}</p>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<p>{device.last_synced ? new Date(device.last_synced).toLocaleString() : 'N/A'}</p>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<p>{device.created_at ? new Date(device.created_at).toLocaleString() : 'N/A'}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
frontend/tailwind.config.js
Normal file
9
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
26
frontend/tsconfig.json
Normal file
26
frontend/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Module resolution options */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* JSX support */
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Strict type checking */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
21
frontend/vite.config.ts
Normal file
21
frontend/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8585',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/assets': {
|
||||||
|
target: 'http://localhost:8585',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user