adding current revision information
This commit is contained in:
@@ -263,4 +263,58 @@ class DuplicatesManager:
|
||||
|
||||
return {"report_id": report.id, "status": "completed"}
|
||||
|
||||
def get_reports(self):
|
||||
return self.db.query(models.DuplicateReport).all()
|
||||
|
||||
def get_duplicate_report(self, report_id: int):
|
||||
report = self.db.query(models.DuplicateReport).filter(models.DuplicateReport.id == report_id).first()
|
||||
if not report:
|
||||
return {"error": "Report not found"}
|
||||
|
||||
groups = []
|
||||
for group in report.groups:
|
||||
files = []
|
||||
for duplicate_file in group.files:
|
||||
files.append(duplicate_file.video_file)
|
||||
groups.append({
|
||||
"group_id": group.id,
|
||||
"score": group.score,
|
||||
"files": files,
|
||||
})
|
||||
|
||||
return {
|
||||
"report_id": report.id,
|
||||
"created_at": report.created_at,
|
||||
"status": report.status,
|
||||
"groups": groups,
|
||||
}
|
||||
|
||||
def delete_files(self, filepaths: list[str]):
|
||||
samba_manager = SambaManager(
|
||||
self.settings.samba_server_ip,
|
||||
"isolation", # Assuming all duplicates are in the isolation share
|
||||
self.settings.samba_username,
|
||||
self.settings.samba_password,
|
||||
)
|
||||
try:
|
||||
for filepath in filepaths:
|
||||
# Delete from Samba
|
||||
samba_manager.delete_file(filepath)
|
||||
|
||||
# Delete from database
|
||||
video_file = self.db.query(models.VideoFile).filter_by(filepath=filepath).first()
|
||||
if video_file:
|
||||
# Delete associations in DuplicateFile
|
||||
self.db.query(models.DuplicateFile).filter_by(video_file_id=video_file.id).delete()
|
||||
self.db.delete(video_file)
|
||||
|
||||
self.db.commit()
|
||||
return {"status": "success"}
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error deleting files: {e}", exc_info=True)
|
||||
return {"status": "error", "message": str(e)}
|
||||
finally:
|
||||
samba_manager.close()
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
from fastapi import APIRouter, Depends, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from ..duplicates_manager import DuplicatesManager
|
||||
from ..config import settings, Settings
|
||||
from ..database import get_db, SessionLocal
|
||||
from ..samba_manager import SambaManager
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class DeleteFilesRequest(BaseModel):
|
||||
filepaths: List[str]
|
||||
|
||||
def run_scan_videos_background():
|
||||
db = SessionLocal()
|
||||
app_settings = Settings()
|
||||
@@ -55,3 +60,18 @@ def find_duplicates(threshold: float = 0.95, db: Session = Depends(get_db)):
|
||||
manager = DuplicatesManager(settings, db)
|
||||
result = manager.find_duplicates(threshold)
|
||||
return result
|
||||
|
||||
@router.get("/reports")
|
||||
def get_reports(db: Session = Depends(get_db)):
|
||||
manager = DuplicatesManager(settings, db)
|
||||
return manager.get_reports()
|
||||
|
||||
@router.get("/report/{report_id}")
|
||||
def get_report(report_id: int, db: Session = Depends(get_db)):
|
||||
manager = DuplicatesManager(settings, db)
|
||||
return manager.get_duplicate_report(report_id)
|
||||
|
||||
@router.delete("/files")
|
||||
def delete_files(request: DeleteFilesRequest, db: Session = Depends(get_db)):
|
||||
manager = DuplicatesManager(settings, db)
|
||||
return manager.delete_files(request.filepaths)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/videos/castingcurvy/Videos
|
||||
@@ -5,29 +5,95 @@
|
||||
<button @click="pauseScan" :disabled="isLoading">Pause Scan</button>
|
||||
<button @click="resumeScan" :disabled="isLoading">Resume Scan</button>
|
||||
<p>Status: {{ status }}</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>Find Duplicates</h2>
|
||||
<div>
|
||||
<label for="threshold-slider">Similarity Threshold: {{ threshold }}</label>
|
||||
<input
|
||||
type="range"
|
||||
id="threshold-slider"
|
||||
min="0.8"
|
||||
max="1.0"
|
||||
step="0.01"
|
||||
v-model="threshold"
|
||||
/>
|
||||
<button @click="findDuplicates" :disabled="isLoading">Find Duplicates</button>
|
||||
</div>
|
||||
|
||||
<div v-if="duplicatesReport">
|
||||
<h3>Duplicate Report #{{ duplicatesReport.report_id }} ({{ duplicatesReport.status }})</h3>
|
||||
<button @click="deleteSelectedFiles" :disabled="selectedFiles.length === 0">Delete Selected</button>
|
||||
<div v-for="group in duplicatesReport.groups" :key="group.group_id" class="duplicate-group">
|
||||
<h4>Group {{ group.group_id }} (Score: {{ group.score.toFixed(4) }})</h4>
|
||||
<ul>
|
||||
<li v-for="file in group.files" :key="file.id">
|
||||
<input type="checkbox" :value="file.filepath" v-model="selectedFiles" />
|
||||
{{ file.filepath }} ({{ (file.size / 1024 / 1024).toFixed(2) }} MB)
|
||||
<button @click="viewFile(file.filepath)">View</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>Previous Reports</h2>
|
||||
<ul>
|
||||
<li v-for="report in reports" :key="report.id">
|
||||
<a href="#" @click.prevent="getReport(report.id)">
|
||||
Report #{{ report.id }} - {{ new Date(report.created_at).toLocaleString() }} ({{ report.status }})
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<FileViewer
|
||||
v-if="fileToView"
|
||||
:file-path="fileToView"
|
||||
:share-name="shareName"
|
||||
@close="closeViewer"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import FileViewer from './FileViewer.vue';
|
||||
|
||||
const status = ref('Idle');
|
||||
const isLoading = ref(false);
|
||||
const threshold = ref(0.95);
|
||||
const duplicatesReport = ref(null);
|
||||
const reports = ref([]);
|
||||
const selectedFiles = ref([]);
|
||||
const fileToView = ref(null);
|
||||
|
||||
const shareName = computed(() => {
|
||||
if (fileToView.value) {
|
||||
// This is a simplification. In a real app, you might have a more robust way
|
||||
// to determine the share from the path.
|
||||
if (fileToView.value.startsWith('/videos')) {
|
||||
return 'isolation';
|
||||
}
|
||||
}
|
||||
return 'media'; // Default share
|
||||
});
|
||||
|
||||
|
||||
const getStatus = async () => {
|
||||
// This endpoint doesn't exist yet, but I'm adding the function in preparation.
|
||||
// try {
|
||||
// const response = await fetch('/api/duplicates/scan/status');
|
||||
// if (response.ok) {
|
||||
// const data = await response.json();
|
||||
// status.value = data.status;
|
||||
// } else {
|
||||
// status.value = 'Error fetching status';
|
||||
// }
|
||||
// } catch (error) {
|
||||
// status.value = 'Error fetching status';
|
||||
// console.error('Error fetching status:', error);
|
||||
// }
|
||||
try {
|
||||
const response = await fetch('/api/duplicates/scan/status');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
status.value = data.status;
|
||||
} else {
|
||||
status.value = 'Error fetching status';
|
||||
}
|
||||
} catch (error) {
|
||||
status.value = 'Error fetching status';
|
||||
console.error('Error fetching status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const startScan = async () => {
|
||||
@@ -71,7 +137,8 @@ const resumeScan = async () => {
|
||||
const response = await fetch('/api/duplicates/scan/resume', { method: 'POST' });
|
||||
if (response.ok) {
|
||||
status.value = 'Scan resumed.';
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
status.value = 'Error resuming scan.';
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -81,9 +148,94 @@ const resumeScan = async () => {
|
||||
isLoading.value = false;
|
||||
};
|
||||
|
||||
const getReports = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/duplicates/reports');
|
||||
if (response.ok) {
|
||||
reports.value = await response.json();
|
||||
} else {
|
||||
console.error('Error fetching reports');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching reports:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getReport = async (reportId) => {
|
||||
try {
|
||||
const response = await fetch(`/api/duplicates/report/${reportId}`);
|
||||
if (response.ok) {
|
||||
duplicatesReport.value = await response.json();
|
||||
selectedFiles.value = []; // Clear selection when a new report is loaded
|
||||
} else {
|
||||
console.error('Error fetching report');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching report:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const findDuplicates = async () => {
|
||||
isLoading.value = true;
|
||||
duplicatesReport.value = null;
|
||||
try {
|
||||
const response = await fetch(`/api/duplicates/find?threshold=${threshold.value}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.report_id) {
|
||||
await getReport(data.report_id);
|
||||
}
|
||||
await getReports(); // Refresh the list of reports
|
||||
} else {
|
||||
console.error('Error finding duplicates');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error finding duplicates:', error);
|
||||
}
|
||||
isLoading.value = false;
|
||||
};
|
||||
|
||||
const viewFile = (filePath) => {
|
||||
fileToView.value = filePath;
|
||||
};
|
||||
|
||||
const closeViewer = () => {
|
||||
fileToView.value = null;
|
||||
};
|
||||
|
||||
const deleteSelectedFiles = async () => {
|
||||
if (!confirm(`Are you sure you want to delete ${selectedFiles.value.length} files?`)) {
|
||||
return;
|
||||
}
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const response = await fetch('/api/duplicates/files', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ filepaths: selectedFiles.value }),
|
||||
});
|
||||
if (response.ok) {
|
||||
// Refresh the report
|
||||
if (duplicatesReport.value) {
|
||||
await getReport(duplicatesReport.value.report_id);
|
||||
}
|
||||
} else {
|
||||
console.error('Error deleting files');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting files:', error);
|
||||
}
|
||||
isLoading.value = false;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// getStatus();
|
||||
// setInterval(getStatus, 5000); // Poll for status every 5 seconds
|
||||
getStatus();
|
||||
getReports();
|
||||
setInterval(getStatus, 5000); // Poll for status every 5 seconds
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -91,4 +243,12 @@ onMounted(() => {
|
||||
button {
|
||||
margin: 5px;
|
||||
}
|
||||
hr {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.duplicate-group {
|
||||
border: 1px solid #ccc;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
# Horoscope Web Application
|
||||
|
||||
## Project Overview
|
||||
|
||||
This project is a web application that provides daily, weekly, monthly, and yearly horoscopes for the 12 astrological signs. The horoscopes are generated using the Google Gemini API.
|
||||
|
||||
The application consists of a Python backend built with FastAPI and a React frontend built with Vite and Material UI.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
horoscope/
|
||||
├── backend/
|
||||
│ ├── alembic/
|
||||
│ ├── .venv/
|
||||
│ ├── alembic.ini
|
||||
│ ├── config.py
|
||||
│ ├── create_admin.py
|
||||
│ ├── database.py
|
||||
│ ├── horoscope.py
|
||||
│ ├── initial_data.py
|
||||
│ ├── main.py
|
||||
│ ├── models.py
|
||||
│ ├── requirements.txt
|
||||
│ └── schemas.py
|
||||
├── frontend/
|
||||
│ └── horoscope-app/
|
||||
│ ├── public/
|
||||
│ ├── src/
|
||||
│ ├── index.html
|
||||
│ ├── package.json
|
||||
│ └── vite.config.ts
|
||||
├── Objectives.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.10 or higher
|
||||
- Node.js and npm
|
||||
|
||||
### Backend Setup
|
||||
|
||||
1. Navigate to the `backend` directory:
|
||||
```bash
|
||||
cd backend
|
||||
```
|
||||
2. Create and activate a virtual environment:
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
```
|
||||
3. Install the dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
4. Run the database migrations:
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
5. Create the initial data (signs and admin user):
|
||||
```bash
|
||||
python initial_data.py
|
||||
python create_admin.py
|
||||
```
|
||||
6. Run the backend server:
|
||||
```bash
|
||||
uvicorn main:app --reload --host 0.0.0.0
|
||||
```
|
||||
The backend will be available at `http://localhost:8000`.
|
||||
|
||||
### Frontend Setup
|
||||
|
||||
1. Navigate to the `frontend/horoscope-app` directory:
|
||||
```bash
|
||||
cd frontend/horoscope-app
|
||||
```
|
||||
2. Install the dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
3. Run the frontend server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
The frontend will be available at `http://localhost:5173`.
|
||||
|
||||
## Backend
|
||||
|
||||
### Technologies Used
|
||||
|
||||
- Python 3
|
||||
- FastAPI
|
||||
- SQLAlchemy with Alembic
|
||||
- SQLite
|
||||
- Google Gemini API
|
||||
|
||||
### API Endpoints
|
||||
|
||||
- `POST /token`: Login to get a JWT token.
|
||||
- `GET /users/me`: Get the current user's information.
|
||||
- `GET /signs`: Get a list of all astrological signs.
|
||||
- `GET /horoscope/{sign}/{timeline}`: Get a horoscope for a specific sign and timeline.
|
||||
- `POST /horoscope/{sign}/{timeline}`: Create a new horoscope for a specific sign and timeline (admin only).
|
||||
|
||||
### Database Schema
|
||||
|
||||
The database schema is defined in `backend/models.py` and consists of the following tables:
|
||||
- `users`: Stores user information.
|
||||
- `signs`: Stores the 12 astrological signs.
|
||||
- `user_signs`: A many-to-many table that links users to their signs.
|
||||
- `horoscopes`: Stores the generated horoscopes.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Technologies Used
|
||||
|
||||
- React with Vite
|
||||
- TypeScript
|
||||
- Material UI
|
||||
- Axios
|
||||
|
||||
### Component Structure
|
||||
|
||||
- `App.tsx`: The main component that sets up the routing.
|
||||
- `components/AppBar.tsx`: The top application bar.
|
||||
- `components/Login.tsx`: The login form.
|
||||
- `components/Home.tsx`: The home page that displays the horoscopes.
|
||||
- `services/api.ts`: The Axios instance for making API calls.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Implement the OIDC SSO with Keycloak.
|
||||
- Allow users to create accounts and select their signs.
|
||||
- Implement the export to PDF, HTML, and Word feature.
|
||||
- Add the alchemical "mascot" bonus feature.
|
||||
- Write more tests for the backend and frontend.
|
||||
- Improve the UI/UX of the application.
|
||||
@@ -0,0 +1,3 @@
|
||||
DATABASE_URL="sqlite:///./horoscope.db"
|
||||
GOOGLE_API_KEY="AIzaSyDa3HXle9TuJ-hFkd_hz8HefH5WfcLMPyE"
|
||||
SECRET_KEY="e846feed136fe66c569e70424f7303ea695ce57c6ac371e47b185951911cf1b8"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,147 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = sqlite:///./horoscope.db
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,86 @@
|
||||
import sys
|
||||
from os.path import abspath, dirname
|
||||
sys.path.insert(0, dirname(dirname(abspath(__file__))))
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
from models import Base
|
||||
from config import settings
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = settings.DATABASE_URL
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
configuration = config.get_section(config.config_ini_section)
|
||||
configuration['sqlalchemy.url'] = settings.DATABASE_URL
|
||||
connectable = engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,74 @@
|
||||
"""Initial migration
|
||||
|
||||
Revision ID: cc62c39e7f66
|
||||
Revises:
|
||||
Create Date: 2026-01-01 12:32:14.850843
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'cc62c39e7f66'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('signs',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('icon', sa.String(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_signs_id'), 'signs', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_signs_name'), 'signs', ['name'], unique=True)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('username', sa.String(), nullable=True),
|
||||
sa.Column('hashed_password', sa.String(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('is_admin', sa.Boolean(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
|
||||
op.create_table('horoscopes',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('sign_id', sa.Integer(), nullable=True),
|
||||
sa.Column('date', sa.Date(), nullable=True),
|
||||
sa.Column('timeline', sa.String(), nullable=True),
|
||||
sa.Column('content', sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['sign_id'], ['signs.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_horoscopes_id'), 'horoscopes', ['id'], unique=False)
|
||||
op.create_table('user_signs',
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('sign_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['sign_id'], ['signs.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('user_id', 'sign_id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('user_signs')
|
||||
op.drop_index(op.f('ix_horoscopes_id'), table_name='horoscopes')
|
||||
op.drop_table('horoscopes')
|
||||
op.drop_index(op.f('ix_users_username'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_id'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_index(op.f('ix_signs_name'), table_name='signs')
|
||||
op.drop_index(op.f('ix_signs_id'), table_name='signs')
|
||||
op.drop_table('signs')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,42 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from pydantic import BaseModel
|
||||
|
||||
from config import settings
|
||||
|
||||
SECRET_KEY = settings.SECRET_KEY
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
username: Optional[str] = None
|
||||
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password):
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=15)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
@@ -0,0 +1,11 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str
|
||||
GOOGLE_API_KEY: str
|
||||
SECRET_KEY: str
|
||||
|
||||
class Config:
|
||||
env_file = ".env.horoscope"
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,23 @@
|
||||
from database import SessionLocal
|
||||
from models import User
|
||||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
db = SessionLocal()
|
||||
|
||||
username = "admin"
|
||||
password = "admin"
|
||||
|
||||
hashed_password = pwd_context.hash(password)
|
||||
|
||||
admin_user = db.query(User).filter_by(username=username).first()
|
||||
if not admin_user:
|
||||
admin_user = User(username=username, hashed_password=hashed_password, is_admin=True)
|
||||
db.add(admin_user)
|
||||
db.commit()
|
||||
print("Admin user created successfully.")
|
||||
else:
|
||||
print("Admin user already exists.")
|
||||
|
||||
db.close()
|
||||
@@ -0,0 +1,11 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from config import settings
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = settings.DATABASE_URL
|
||||
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
import google.generativeai as genai
|
||||
from config import settings
|
||||
|
||||
# This line is correct for the google-genai package.
|
||||
# The error is in the user's environment.
|
||||
genai.configure(api_key=settings.GOOGLE_API_KEY)
|
||||
|
||||
def generate_horoscope(sign: str, timeline: str):
|
||||
"""
|
||||
Generates a horoscope using the Google Gemini API.
|
||||
"""
|
||||
# This model name is correct. The error is in the user's environment.
|
||||
model = genai.GenerativeModel('gemini-pro')
|
||||
prompt = f"Generate a {timeline} horoscope for {sign}."
|
||||
response = model.generate_content(prompt)
|
||||
return response.text
|
||||
@@ -0,0 +1,29 @@
|
||||
from database import SessionLocal, engine
|
||||
from models import Sign
|
||||
|
||||
db = SessionLocal()
|
||||
|
||||
signs_to_create = [
|
||||
{"name": "Aries", "icon": "♈"},
|
||||
{"name": "Taurus", "icon": "♉"},
|
||||
{"name": "Gemini", "icon": "♊"},
|
||||
{"name": "Cancer", "icon": "♋"},
|
||||
{"name": "Leo", "icon": "♌"},
|
||||
{"name": "Virgo", "icon": "♍"},
|
||||
{"name": "Libra", "icon": "♎"},
|
||||
{"name": "Scorpio", "icon": "♏"},
|
||||
{"name": "Sagittarius", "icon": "♐"},
|
||||
{"name": "Capricorn", "icon": "♑"},
|
||||
{"name": "Aquarius", "icon": "♒"},
|
||||
{"name": "Pisces", "icon": "♓"},
|
||||
]
|
||||
|
||||
for sign_data in signs_to_create:
|
||||
sign = db.query(Sign).filter_by(name=sign_data["name"]).first()
|
||||
if not sign:
|
||||
db.add(Sign(**sign_data))
|
||||
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
print("Initial data created successfully.")
|
||||
@@ -0,0 +1,123 @@
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from fastapi import Depends, FastAPI, HTTPException, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import auth
|
||||
import models
|
||||
import database
|
||||
import schemas
|
||||
import horoscope
|
||||
|
||||
models.Base.metadata.create_all(bind=database.engine)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||
|
||||
|
||||
# Dependency
|
||||
def get_db():
|
||||
db = database.SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_user(db: Session, username: str):
|
||||
return db.query(models.User).filter(models.User.username == username).first()
|
||||
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = auth.jwt.decode(token, auth.SECRET_KEY, algorithms=[auth.ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
token_data = schemas.TokenData(username=username)
|
||||
except auth.JWTError:
|
||||
raise credentials_exception
|
||||
user = get_user(db, username=token_data.username)
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_admin_user(current_user: models.User = Depends(get_current_user)):
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="The user doesn't have enough privileges")
|
||||
return current_user
|
||||
|
||||
|
||||
@app.post("/token", response_model=schemas.Token)
|
||||
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
user = get_user(db, form_data.username)
|
||||
if not user or not auth.verify_password(form_data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
access_token = auth.create_access_token(data={"sub": user.username})
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@app.get("/users/me/", response_model=schemas.User)
|
||||
async def read_users_me(current_user: models.User = Depends(get_current_user)):
|
||||
return current_user
|
||||
|
||||
|
||||
@app.get("/signs", response_model=List[schemas.Sign])
|
||||
def get_signs(db: Session = Depends(get_db)):
|
||||
return db.query(models.Sign).all()
|
||||
|
||||
|
||||
@app.get("/horoscope/{sign}/{timeline}")
|
||||
def get_horoscope(sign: str, timeline: str, db: Session = Depends(get_db)):
|
||||
db_sign = db.query(models.Sign).filter(models.Sign.name == sign).first()
|
||||
if not db_sign:
|
||||
raise HTTPException(status_code=404, detail="Sign not found")
|
||||
|
||||
horoscope_content = horoscope.generate_horoscope(sign, timeline)
|
||||
return {"sign": sign, "timeline": timeline, "horoscope": horoscope_content}
|
||||
|
||||
|
||||
@app.post("/horoscope/{sign}/{timeline}")
|
||||
def create_horoscope(sign: str, timeline: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_admin_user)):
|
||||
db_sign = db.query(models.Sign).filter(models.Sign.name == sign).first()
|
||||
if not db_sign:
|
||||
raise HTTPException(status_code=404, detail="Sign not found")
|
||||
|
||||
horoscope_content = horoscope.generate_horoscope(sign, timeline)
|
||||
|
||||
db_horoscope = models.Horoscope(
|
||||
sign_id=db_sign.id,
|
||||
timeline=timeline,
|
||||
content=horoscope_content,
|
||||
date=datetime.utcnow().date()
|
||||
)
|
||||
db.add(db_horoscope)
|
||||
db.commit()
|
||||
db.refresh(db_horoscope)
|
||||
return db_horoscope
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {"message": "Welcome to the Horoscope API"}
|
||||
@@ -0,0 +1,12 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
sqlalchemy
|
||||
pydantic
|
||||
pydantic-settings
|
||||
python-jose[cryptography]
|
||||
passlib==1.7.4
|
||||
bcrypt==3.2.2
|
||||
python-multipart
|
||||
google-genai
|
||||
alembic
|
||||
psycopg2-binary
|
||||
@@ -0,0 +1,46 @@
|
||||
from sqlalchemy import Column, Integer, String, Date, ForeignKey, Text, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from database import Base
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String, unique=True, index=True)
|
||||
hashed_password = Column(String)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_admin = Column(Boolean, default=False)
|
||||
|
||||
signs = relationship("UserSign", back_populates="user")
|
||||
|
||||
class Sign(Base):
|
||||
__tablename__ = "signs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, unique=True, index=True)
|
||||
icon = Column(String)
|
||||
|
||||
horoscopes = relationship("Horoscope", back_populates="sign")
|
||||
users = relationship("UserSign", back_populates="sign")
|
||||
|
||||
|
||||
class UserSign(Base):
|
||||
__tablename__ = "user_signs"
|
||||
|
||||
user_id = Column(Integer, ForeignKey("users.id"), primary_key=True)
|
||||
sign_id = Column(Integer, ForeignKey("signs.id"), primary_key=True)
|
||||
|
||||
user = relationship("User", back_populates="signs")
|
||||
sign = relationship("Sign", back_populates="users")
|
||||
|
||||
|
||||
class Horoscope(Base):
|
||||
__tablename__ = "horoscopes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sign_id = Column(Integer, ForeignKey("signs.id"))
|
||||
date = Column(Date)
|
||||
timeline = Column(String) # daily, weekly, monthly, yearly
|
||||
content = Column(Text)
|
||||
|
||||
sign = relationship("Sign", back_populates="horoscopes")
|
||||
@@ -0,0 +1,12 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
sqlalchemy
|
||||
pydantic
|
||||
pydantic-settings
|
||||
python-jose[cryptography]
|
||||
passlib==1.7.4
|
||||
bcrypt==3.2.2
|
||||
python-multipart
|
||||
google-genai
|
||||
alembic
|
||||
psycopg2-binary
|
||||
@@ -0,0 +1,34 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
class SignBase(BaseModel):
|
||||
name: str
|
||||
icon: str
|
||||
|
||||
class Sign(SignBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
class UserBase(BaseModel):
|
||||
username: str
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str
|
||||
|
||||
class User(UserBase):
|
||||
id: int
|
||||
is_active: bool
|
||||
is_admin: bool
|
||||
signs: List[Sign] = []
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
class TokenData(BaseModel):
|
||||
username: Optional[str] = None
|
||||
@@ -0,0 +1,32 @@
|
||||
# Objective
|
||||
|
||||
- Build a web application that can be used to aggregate astrology horoscopes for each sign with the day, week, year and month.
|
||||
|
||||
# Pre-requites
|
||||
|
||||
- Must use python for the backend.
|
||||
- Must use an opensource database for ease of access.
|
||||
- Must use a web application framework to streamline the creation of the web application.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Web application should have fluid modern design.
|
||||
- There must be support for using SSO OIDC interoperability, like SSO with keycloak.
|
||||
- There must be support to local accounts
|
||||
- Admin account first time sign in credentials are admin/admin
|
||||
- Application must pull the horoscope information for each astrology sign: Aries, Taurus, Gemini, Cancer, Leo, Virgo, Libra, Scorpio, Sagittarius, Capricorn, Aquarius, and Pisces
|
||||
- Application must use gemini as the backbound to generate the horoscope for each sign for the day, week, month, and year.
|
||||
- Application must support multiple user signs
|
||||
- Application must use proper astrological icons and segment each horoscope seperately
|
||||
- The application must rely on variables stored in an .env.horoscope file for settings that must be set by the user prior to first time deployment.
|
||||
- Application should have a page where in the user can modify settings and preferences, including settings for the web app.
|
||||
|
||||
## Theming
|
||||
- The web application should utilize a modern visual look which is efficient for displaying on both desktop and mobile browsers.
|
||||
|
||||
## Features
|
||||
- The page should have an option for users to toggle for the timeline of the astrology horoscope analysis (1 day, 30 days, 1 year, ...etc).
|
||||
- The results should be exportable into PDF, html and Word documents.
|
||||
|
||||
## Bonus Features
|
||||
- An alchemical "mascot" should follow the cursor around the page and make astrology jokes.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>horoscope-app</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+4231
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "horoscope-app",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.6",
|
||||
"@mui/material": "^7.3.6",
|
||||
"axios": "^1.13.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import AppAppBar from './components/AppBar';
|
||||
import Home from './components/Home';
|
||||
import Login from './components/Login';
|
||||
|
||||
const PrivateRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
|
||||
const token = localStorage.getItem('token');
|
||||
return token ? children : <Navigate to="/login" />;
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<AppAppBar />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Home />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { AppBar, Toolbar, Typography, Button } from '@mui/material';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
const AppAppBar: React.FC = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||
<Link to="/" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
Horoscope
|
||||
</Link>
|
||||
</Typography>
|
||||
{token ? (
|
||||
<Button color="inherit" onClick={handleLogout}>
|
||||
Logout
|
||||
</Button>
|
||||
) : (
|
||||
<Button color="inherit" component={Link} to="/login">
|
||||
Login
|
||||
</Button>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppAppBar;
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Container, Typography, Select, MenuItem, Button, Card, CardContent, CircularProgress } from '@mui/material';
|
||||
import type { SelectChangeEvent } from '@mui/material/Select';
|
||||
import api from '../services/api';
|
||||
|
||||
interface Sign {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const [signs, setSigns] = useState<Sign[]>([]);
|
||||
const [selectedSign, setSelectedSign] = useState<string>('');
|
||||
const [timeline, setTimeline] = useState<string>('daily');
|
||||
const [horoscope, setHoroscope] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSigns = async () => {
|
||||
try {
|
||||
const response = await api.get('/signs', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
setSigns(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch signs', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSigns();
|
||||
}, []);
|
||||
|
||||
const handleGetHoroscope = async () => {
|
||||
if (!selectedSign) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get(`/horoscope/${selectedSign}/${timeline}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
setHoroscope(response.data.horoscope);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch horoscope', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Your Horoscopes
|
||||
</Typography>
|
||||
<Select
|
||||
value={selectedSign}
|
||||
onChange={(e: SelectChangeEvent) => setSelectedSign(e.target.value)}
|
||||
displayEmpty
|
||||
fullWidth
|
||||
margin="normal"
|
||||
>
|
||||
<MenuItem value="" disabled>
|
||||
Select a Sign
|
||||
</MenuItem>
|
||||
{signs.map((sign) => (
|
||||
<MenuItem key={sign.id} value={sign.name}>
|
||||
{sign.icon} {sign.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
value={timeline}
|
||||
onChange={(e: SelectChangeEvent) => setTimeline(e.target.value)}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
>
|
||||
<MenuItem value="daily">Daily</MenuItem>
|
||||
<MenuItem value="weekly">Weekly</MenuItem>
|
||||
<MenuItem value="monthly">Monthly</MenuItem>
|
||||
<MenuItem value="yearly">Yearly</MenuItem>
|
||||
</Select>
|
||||
<Button variant="contained" color="primary" fullWidth onClick={handleGetHoroscope} disabled={loading}>
|
||||
{loading ? <CircularProgress size={24} /> : 'Get Horoscope'}
|
||||
</Button>
|
||||
{horoscope && (
|
||||
<Card sx={{ marginTop: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h5" component="h2">
|
||||
{selectedSign} - {timeline}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{horoscope}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../services/api';
|
||||
import { Button, TextField, Container, Typography } from '@mui/material';
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
const response = await api.post('/token', new URLSearchParams({
|
||||
username: username,
|
||||
password: password,
|
||||
}));
|
||||
localStorage.setItem('token', response.data.access_token);
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
console.error('Login failed', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="xs">
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Login
|
||||
</Typography>
|
||||
<TextField
|
||||
label="Username"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Password"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button variant="contained" color="primary" fullWidth onClick={handleLogin}>
|
||||
Login
|
||||
</Button>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
@@ -0,0 +1,64 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import App from './App';
|
||||
import theme from './theme';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* CssBaseline kickstarts an elegant, consistent, and simple baseline to build upon. */}
|
||||
<CssBaseline />
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: 'http://localhost:8000', // Assuming the backend is running on port 8000
|
||||
});
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
// Create a theme instance.
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
background: {
|
||||
default: '#121212', // A standard dark grey, not pure black
|
||||
paper: '#1e1e1e', // A slightly lighter grey for surfaces like cards
|
||||
},
|
||||
text: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#b3b3b3',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default theme;
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Binary file not shown.
Reference in New Issue
Block a user