This commit is contained in:
2026-03-15 21:01:29 -04:00
parent d40f8fc375
commit 4306d86080
73 changed files with 13106 additions and 63 deletions
+961 -14
View File
File diff suppressed because it is too large Load Diff
+170 -6
View File
@@ -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
View 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)
}
+550 -16
View File
@@ -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'
+103 -15
View File
@@ -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
View 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 -1
View File
@@ -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
} }
+15
View File
@@ -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
View File
@@ -0,0 +1 @@
node_modules
+111
View 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.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+32
View 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
View 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
View 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
View File
File diff suppressed because it is too large Load Diff
+30
View 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
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+12
View File
@@ -0,0 +1,12 @@
import { AuthProvider } from './auth/AuthContext';
import { Routes } from './Routes';
function App() {
return (
<AuthProvider>
<Routes />
</AuthProvider>
);
}
export default App;
+77
View 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
View 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
View 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
View 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
View 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>
);
}
File diff suppressed because it is too large Load Diff
+15
View 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;
}
@@ -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;
}
@@ -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;
};
@@ -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
View 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
View 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;
}
@@ -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;
}
@@ -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[];
}
@@ -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;
}
@@ -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;
};
@@ -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;
};
@@ -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;
};
@@ -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;
};
@@ -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;
@@ -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;
}
@@ -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;
}
@@ -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
View 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';
@@ -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[];
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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
View 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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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
View 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;
}
@@ -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[];
}
@@ -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
View 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;
}
@@ -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;
}
@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File
@@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
};
+26
View 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
View 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',
},
});