adding current revision information

This commit is contained in:
2026-01-02 08:29:16 -05:00
parent 789465483c
commit 920947e556
64 changed files with 5963 additions and 18 deletions
@@ -263,4 +263,58 @@ class DuplicatesManager:
return {"report_id": report.id, "status": "completed"} 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 fastapi import APIRouter, Depends, BackgroundTasks
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from pydantic import BaseModel
from ..duplicates_manager import DuplicatesManager from ..duplicates_manager import DuplicatesManager
from ..config import settings, Settings from ..config import settings, Settings
from ..database import get_db, SessionLocal from ..database import get_db, SessionLocal
from ..samba_manager import SambaManager from ..samba_manager import SambaManager
import os import os
from typing import List
router = APIRouter() router = APIRouter()
class DeleteFilesRequest(BaseModel):
filepaths: List[str]
def run_scan_videos_background(): def run_scan_videos_background():
db = SessionLocal() db = SessionLocal()
app_settings = Settings() app_settings = Settings()
@@ -55,3 +60,18 @@ def find_duplicates(threshold: float = 0.95, db: Session = Depends(get_db)):
manager = DuplicatesManager(settings, db) manager = DuplicatesManager(settings, db)
result = manager.find_duplicates(threshold) result = manager.find_duplicates(threshold)
return result 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="pauseScan" :disabled="isLoading">Pause Scan</button>
<button @click="resumeScan" :disabled="isLoading">Resume Scan</button> <button @click="resumeScan" :disabled="isLoading">Resume Scan</button>
<p>Status: {{ status }}</p> <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> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, onMounted, computed } from 'vue';
import FileViewer from './FileViewer.vue';
const status = ref('Idle'); const status = ref('Idle');
const isLoading = ref(false); 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 () => { const getStatus = async () => {
// This endpoint doesn't exist yet, but I'm adding the function in preparation. try {
// try { const response = await fetch('/api/duplicates/scan/status');
// const response = await fetch('/api/duplicates/scan/status'); if (response.ok) {
// if (response.ok) { const data = await response.json();
// const data = await response.json(); status.value = data.status;
// status.value = data.status; } else {
// } else { status.value = 'Error fetching status';
// status.value = 'Error fetching status'; }
// } } catch (error) {
// } catch (error) { status.value = 'Error fetching status';
// status.value = 'Error fetching status'; console.error('Error fetching status:', error);
// console.error('Error fetching status:', error); }
// }
}; };
const startScan = async () => { const startScan = async () => {
@@ -71,7 +137,8 @@ const resumeScan = async () => {
const response = await fetch('/api/duplicates/scan/resume', { method: 'POST' }); const response = await fetch('/api/duplicates/scan/resume', { method: 'POST' });
if (response.ok) { if (response.ok) {
status.value = 'Scan resumed.'; status.value = 'Scan resumed.';
} else { }
else {
status.value = 'Error resuming scan.'; status.value = 'Error resuming scan.';
} }
} catch (error) { } catch (error) {
@@ -81,9 +148,94 @@ const resumeScan = async () => {
isLoading.value = false; 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(() => { onMounted(() => {
// getStatus(); getStatus();
// setInterval(getStatus, 5000); // Poll for status every 5 seconds getReports();
setInterval(getStatus, 5000); // Poll for status every 5 seconds
}); });
</script> </script>
@@ -91,4 +243,12 @@ onMounted(() => {
button { button {
margin: 5px; margin: 5px;
} }
hr {
margin: 20px 0;
}
.duplicate-group {
border: 1px solid #ccc;
padding: 10px;
margin-bottom: 10px;
}
</style> </style>
+140
View File
@@ -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.
+3
View File
@@ -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.
+147
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+86
View File
@@ -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()
+28
View File
@@ -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"}
@@ -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 ###
+42
View File
@@ -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
+11
View File
@@ -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()
+23
View File
@@ -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()
+11
View File
@@ -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.
+16
View File
@@ -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
+29
View File
@@ -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.")
+123
View File
@@ -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
+46
View File
@@ -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")
+12
View File
@@ -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
+34
View File
@@ -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
+32
View File
@@ -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>
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.