mirror of
https://github.com/DarrylNixon/ghostforge
synced 2024-04-22 06:27:20 -07:00
Layout work. Learning how to Alembic.
This commit is contained in:
parent
1fa3d8a372
commit
7598e2c8fc
37 changed files with 806 additions and 173 deletions
|
@ -1,35 +0,0 @@
|
|||
from fastapi import Body
|
||||
from fastapi import Depends
|
||||
from fastapi import FastAPI
|
||||
|
||||
from ghostforge.auth import check_user
|
||||
from ghostforge.auth.bearer import JWTBearer
|
||||
from ghostforge.auth.handler import signJWT
|
||||
from ghostforge.models import UserLoginSchema
|
||||
|
||||
# define("port", default=os.environ.get("GHOSTFORGE_INTERNAL_WEB_PORT", 1337), help="Webserver port", type=int)
|
||||
# define("db_host", default=os.environ.get("DATABASE_CONTAINER_NAME", "localhost"), help="Host/IP of db")
|
||||
# define("db_port", default=os.environ.get("DATABASE_PORT", 5432), help="Port of db", type=int)
|
||||
# define("db_database", default=os.environ.get("DATABASE_NAME", "ghostforge"), help="Name of db")
|
||||
# define("db_user", default=os.environ.get("DATABASE_USER", "ghostforge"), help="User with access to db")
|
||||
# define("db_password", default=os.environ.get("DATABASE_PASSWORD", "secure"), help="Password for db user")
|
||||
# define("secret_key", default=os.environ.get("DATABASE_PASSWORD", "secure"), help="Password for db user")
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.get("/", tags=["root"])
|
||||
async def read_root() -> dict:
|
||||
return {"message": "Welcome!"}
|
||||
|
||||
|
||||
@app.post("/login", tags=["user"])
|
||||
async def user_login(user: UserLoginSchema = Body(...)):
|
||||
if check_user(user):
|
||||
return signJWT(user.name)
|
||||
return {"error": "Wrong login details!"}
|
||||
|
||||
|
||||
@app.post("/auth_check", dependencies=[Depends(JWTBearer())], tags=["auth_check"])
|
||||
async def auth_check(post) -> dict:
|
||||
return {"data": "post added."}
|
|
@ -1,11 +0,0 @@
|
|||
from ghostforge.models import UserLoginSchema
|
||||
|
||||
users = [{"name": "test", "password": "pw"}]
|
||||
|
||||
|
||||
def check_user(data: UserLoginSchema):
|
||||
print(data)
|
||||
for user in users:
|
||||
if user["name"] == data.name and user["password"] == data.password:
|
||||
return True
|
||||
return False
|
|
@ -1,35 +0,0 @@
|
|||
from fastapi import HTTPException
|
||||
from fastapi import Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from fastapi.security import HTTPBearer
|
||||
|
||||
from ghostforge.auth.handler import decodeJWT
|
||||
|
||||
# from https://github.com/testdrivenio/fastapi-jwt/
|
||||
|
||||
|
||||
class JWTBearer(HTTPBearer):
|
||||
def __init__(self, auto_error: bool = True):
|
||||
super(JWTBearer, self).__init__(auto_error=auto_error)
|
||||
|
||||
async def __call__(self, request: Request):
|
||||
credentials: HTTPAuthorizationCredentials = await super(JWTBearer, self).__call__(request)
|
||||
if credentials:
|
||||
if not credentials.scheme == "Bearer":
|
||||
raise HTTPException(status_code=403, detail="Invalid authentication scheme.")
|
||||
if not self.verify_jwt(credentials.credentials):
|
||||
raise HTTPException(status_code=403, detail="Invalid token or expired token.")
|
||||
return credentials.credentials
|
||||
else:
|
||||
raise HTTPException(status_code=403, detail="Invalid authorization code.")
|
||||
|
||||
def verify_jwt(self, jwtoken: str) -> bool:
|
||||
isTokenValid: bool = False
|
||||
|
||||
try:
|
||||
payload = decodeJWT(jwtoken)
|
||||
except Exception:
|
||||
payload = None
|
||||
if payload:
|
||||
isTokenValid = True
|
||||
return isTokenValid
|
|
@ -1,28 +0,0 @@
|
|||
import os
|
||||
import time
|
||||
from typing import Dict
|
||||
|
||||
import jwt
|
||||
|
||||
# Based on handler from https://github.com/testdrivenio/fastapi-jwt/
|
||||
|
||||
JWT_SECRET = os.environ.get("GHOSTFORGE_JWT_SECRET")
|
||||
|
||||
|
||||
def token_response(token: str):
|
||||
return {"access_token": token}
|
||||
|
||||
|
||||
def signJWT(user: str) -> Dict[str, str]:
|
||||
payload = {"user_id": user, "expires": time.time() + 600}
|
||||
token = jwt.encode(payload, JWT_SECRET, algorithm="HS256")
|
||||
|
||||
return token_response(token)
|
||||
|
||||
|
||||
def decodeJWT(token: str) -> dict:
|
||||
try:
|
||||
decoded_token = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
|
||||
return decoded_token if decoded_token["expires"] >= time.time() else None
|
||||
except Exception:
|
||||
return {}
|
|
@ -1,9 +0,0 @@
|
|||
import os
|
||||
|
||||
import uvicorn
|
||||
|
||||
|
||||
def start_api() -> None:
|
||||
uvicorn.run(
|
||||
"ghostforge.api:app", host="0.0.0.0", port=os.environ.get("GHOSTFORGE_INTERNAL_WEB_PORT", 1337), reload=True
|
||||
)
|
20
ghostforge/db.py
Normal file
20
ghostforge/db.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
import os
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
database_url = (
|
||||
f'postgresql+asyncpg://{os.environ.get("POSTGRES_USER")}:'
|
||||
+ f'{os.environ.get("POSTGRES_PASSWORD")}@'
|
||||
+ os.environ.get("POSTGRES_CONTAINER")
|
||||
+ f':5432/{os.environ.get("POSTGRES_DB")}'
|
||||
)
|
||||
|
||||
engine = create_async_engine(database_url, echo=True, future=True)
|
||||
|
||||
|
||||
async def get_session() -> AsyncSession:
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with async_session() as session:
|
||||
yield session
|
|
@ -1,27 +1,8 @@
|
|||
import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import Field
|
||||
from sqlmodel import Field
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class UserSchema(BaseModel):
|
||||
name: str = Field(...)
|
||||
password: str = Field(...)
|
||||
created: datetime.datetime = datetime.datetime
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"name": "Jeremy Tootsieroll",
|
||||
"password": "notarealpassword",
|
||||
"created": "2021-03-05T08:21:00.000Z",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class UserLoginSchema(BaseModel):
|
||||
name: str = Field(...)
|
||||
password: str = Field(...)
|
||||
|
||||
class Config:
|
||||
schema_extra = {"example": {"name": "Jeremy Tootsieroll", "password": "notarealpassword"}}
|
||||
class User(SQLModel, table=True):
|
||||
id: int = Field(default=None, primary_key=True)
|
||||
name: str = Field(unique=True)
|
||||
password: str
|
||||
|
|
11
ghostforge/serve.py
Normal file
11
ghostforge/serve.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from users import gf as gf_users
|
||||
|
||||
# from ghostforge.models import User
|
||||
|
||||
gf = FastAPI()
|
||||
gf.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
gf.include_router(gf_users)
|
53
ghostforge/static/css/ghostforge.css
Normal file
53
ghostforge/static/css/ghostforge.css
Normal file
|
@ -0,0 +1,53 @@
|
|||
.nav-menus {
|
||||
margin-top: 6px;
|
||||
padding: 0 .75rem;
|
||||
}
|
||||
|
||||
.nav-menus .nav-item {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.nav-menus .nav-item.header {
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-nav {
|
||||
padding-left: 1.3rem;
|
||||
}
|
||||
|
||||
.version-string {
|
||||
background: #303742;
|
||||
border-radius: .1rem;
|
||||
color: #fff;
|
||||
font-size: .7rem;
|
||||
line-height: 1.25;
|
||||
padding: .1rem .2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
img.brand {
|
||||
margin: 6px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
img.version-size {
|
||||
height: .7rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
summary.left-nav {
|
||||
display: block;
|
||||
}
|
||||
|
||||
summary.left-nav::before {
|
||||
margin-left: 1ch;
|
||||
display: inline-block;
|
||||
transition: 0.2s;
|
||||
content: '\203A';
|
||||
}
|
||||
|
||||
details[open] summary.left-nav::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
1
ghostforge/static/css/spectre-exp.min.css
vendored
Normal file
1
ghostforge/static/css/spectre-exp.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
ghostforge/static/css/spectre-icons.min.css
vendored
Normal file
1
ghostforge/static/css/spectre-icons.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
ghostforge/static/css/spectre.min.css
vendored
Normal file
1
ghostforge/static/css/spectre.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
ghostforge/static/img/default-avatar.png
Normal file
BIN
ghostforge/static/img/default-avatar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
ghostforge/static/img/favicon.ico
Normal file
BIN
ghostforge/static/img/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 239 KiB |
BIN
ghostforge/static/img/ghostforge-sidebar.png
Normal file
BIN
ghostforge/static/img/ghostforge-sidebar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.6 KiB |
BIN
ghostforge/static/img/github-inverted.png
Normal file
BIN
ghostforge/static/img/github-inverted.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 885 B |
104
ghostforge/static/js/ghostforge.js
Normal file
104
ghostforge/static/js/ghostforge.js
Normal file
|
@ -0,0 +1,104 @@
|
|||
// Save and restore navigation menu state from local storage.
|
||||
|
||||
function saveMenuState() {
|
||||
var leftNavigation = document.getElementById("left-navigation");
|
||||
if (!leftNavigation) {
|
||||
return;
|
||||
}
|
||||
var rootMenuItems = leftNavigation.getElementsByTagName("details");
|
||||
console.log(rootMenuItems);
|
||||
var rootMenuStates = {};
|
||||
for (var i = 0; i < rootMenuItems.length; i++) {
|
||||
var item = rootMenuItems[i];
|
||||
var id = item.getAttribute("id");
|
||||
var isOpen = item.hasAttribute("open");
|
||||
rootMenuStates[id] = isOpen;
|
||||
}
|
||||
var jsonMenuStates = JSON.stringify(rootMenuStates);
|
||||
localStorage.setItem("menuState", jsonMenuStates);
|
||||
}
|
||||
|
||||
function restoreMenuState() {
|
||||
var leftNavigation = document.getElementById("left-navigation");
|
||||
if (!leftNavigation) {
|
||||
return;
|
||||
}
|
||||
var jsonMenuStates = localStorage.getItem("menuState");
|
||||
var rootMenuStates = JSON.parse(jsonMenuStates) || {};
|
||||
var rootMenuItems = leftNavigation.getElementsByTagName("details");
|
||||
for (var i = 0; i < rootMenuItems.length; i++) {
|
||||
var item = rootMenuItems[i];
|
||||
var id = item.getAttribute("id");
|
||||
var isOpen = rootMenuStates[id];
|
||||
if (isOpen === undefined) {
|
||||
isOpen = false;
|
||||
}
|
||||
if (isOpen) {
|
||||
item.setAttribute("open", "");
|
||||
} else {
|
||||
item.removeAttribute("open");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trap tab-targeting for ADA-compliant use
|
||||
// https://scribe.bus-hit.me/how-to-trap-focus-inside-modal-to-make-it-ada-compliant-6a50f9a70700
|
||||
function modal_focus(modal_name) {
|
||||
const focusableElements =
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
const modal = document.querySelector(modal_name);
|
||||
|
||||
const firstFocusableElement = modal.querySelectorAll(focusableElements)[0];
|
||||
const focusableContent = modal.querySelectorAll(focusableElements);
|
||||
const lastFocusableElement = focusableContent[focusableContent.length - 1];
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
let isTabPressed = e.key === 'Tab' || e.keyCode === 9;
|
||||
|
||||
if (!isTabPressed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstFocusableElement) {
|
||||
lastFocusableElement.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastFocusableElement) {
|
||||
firstFocusableElement.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
firstFocusableElement.focus();
|
||||
}
|
||||
|
||||
// Hotkeys
|
||||
document.addEventListener("keydown", function (event) {
|
||||
// Ctrl/Cmd+G for search modal
|
||||
if (event.key == "g") {
|
||||
const nav_search_button = document.getElementById("nav_search_button");
|
||||
const search_box = document.getElementById("search_string");
|
||||
console.log(search_box);
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault(); // prevent default Ctrl+G behavior (find in page)
|
||||
nav_search_button.click();
|
||||
search_box.focus();
|
||||
}
|
||||
// Add support for Apple+G on Macs
|
||||
if (event.metaKey) {
|
||||
event.preventDefault(); // prevent default Apple+G behavior (bookmark)
|
||||
nav_search_button.click();
|
||||
search_box.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Run various onload/unload functions since script is loaded before elements are
|
||||
window.onload = function () {
|
||||
restoreMenuState();
|
||||
modal_focus("#search-modal");
|
||||
};
|
||||
window.onbeforeunload = saveMenuState;
|
45
ghostforge/templates.py
Normal file
45
ghostforge/templates.py
Normal file
|
@ -0,0 +1,45 @@
|
|||
import importlib.metadata
|
||||
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# Inject version string and Github URL into every template for navbar display.
|
||||
try:
|
||||
gf_version = importlib.metadata.version(__package__)
|
||||
except ValueError:
|
||||
gf_version = "Local"
|
||||
templates.env.globals["gf_version"] = f"v{gf_version}"
|
||||
templates.env.globals["gf_repository_url"] = "https://github.com/DarrylNixon/ghostforge"
|
||||
|
||||
# Same, but build the navbar from an ordered dictionary for centralization.
|
||||
# Since 3.7 (we require >= 3.9), dicts are guaranteed ordered as inserted.
|
||||
templates.env.globals["gf_navbar"] = {
|
||||
"Management": {
|
||||
"Dashboard": "/user/0",
|
||||
"New Ghost": "/user/0",
|
||||
"Active Ghosts": "/user/0",
|
||||
"Archived Ghosts": "/user/0",
|
||||
},
|
||||
"Research": {
|
||||
"Guidebook": "/user/0",
|
||||
"Cheat Sheet": "/user/0",
|
||||
},
|
||||
"Settings": {
|
||||
"Your Profile": "/user/0",
|
||||
"Configuration": "/user/0",
|
||||
"Integrations": "/user/0",
|
||||
"Manage Users": "/user/0",
|
||||
},
|
||||
"Meta": {
|
||||
"About GhostForge": "/user/0",
|
||||
"System Logs": "/user/0",
|
||||
"Logout": "/user/0",
|
||||
},
|
||||
}
|
||||
|
||||
templates.env.globals["avatar_menu"] = {
|
||||
"Dashboard": "/user/0",
|
||||
"Your Profile": "/user/0",
|
||||
"Logout": "/user/0",
|
||||
}
|
29
ghostforge/templates/base.html
Normal file
29
ghostforge/templates/base.html
Normal file
|
@ -0,0 +1,29 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', path='/img/favicon.ico') }}">
|
||||
<title>{% block title %}{{ title }}{% endblock title %}</title>
|
||||
<link href="{{ url_for('static', path='/css/spectre.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', path='/css/spectre-exp.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', path='/css/spectre-icons.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', path='/css/ghostforge.css') }}" rel="stylesheet">
|
||||
<script src="{{ url_for('static', path='/js/ghostforge.js') }}"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="off-canvas off-canvas-sidebar-show d-flex">
|
||||
{% include "navigation/side.html" %}
|
||||
<div class="off-canvas-content">
|
||||
<div class="container">
|
||||
{% include "navigation/top.html" %}
|
||||
{% block content %}
|
||||
{% endblock content %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
40
ghostforge/templates/modals/search.html
Normal file
40
ghostforge/templates/modals/search.html
Normal file
|
@ -0,0 +1,40 @@
|
|||
<div align="center">
|
||||
<a class="btn btn-primary btn-sm" href="#search-modal" id="nav_search_button">
|
||||
<i class="icon icon-search"></i>
|
||||
⌘-g
|
||||
</a>
|
||||
</div>
|
||||
<div class="modal modal-sm" id="search-modal"><a class="modal-overlay" href="#modals-sizes" aria-label="Close"></a>
|
||||
<div class="modal-container" role="document">
|
||||
<div class="modal-header"><a class="btn btn-clear float-right" href="#modals-sizes" aria-label="Close"></a>
|
||||
<div class="modal-title h5">Search Ghosts</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="content">
|
||||
<form>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="search_string">
|
||||
<h6>Search Term</h6>
|
||||
</label>
|
||||
<input class="form-input" id="search_string" type="text" placeholder="Type a string here">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<h6>Scope</h6>
|
||||
</label>
|
||||
<label class="form-radio">
|
||||
<input type="radio" name="scope"><i class="form-icon"></i> All text fields
|
||||
</label>
|
||||
<label class="form-radio">
|
||||
<input type="radio" name="scope" checked=""><i class="form-icon"></i> Name fields only
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary">Search</button>
|
||||
<a class="btn btn-link" href="#modals-sizes" aria-label="Close">Close</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
40
ghostforge/templates/navigation/side.html
Normal file
40
ghostforge/templates/navigation/side.html
Normal file
|
@ -0,0 +1,40 @@
|
|||
<a class="off-canvas-toggle btn btn-primary btn-action" href="#left-navigation"><i class="icon icon-menu"></i></a>
|
||||
<div id="left-navigation" class="off-canvas-sidebar">
|
||||
<div class="navigation-menu">
|
||||
<a href="/" class="logo">
|
||||
<img src="{{ url_for('static', path='/img/ghostforge-sidebar.png') }}" class="brand">
|
||||
</a>
|
||||
<div>{% include "modals/search.html" %}</div>
|
||||
<div class="nav-menus">
|
||||
{% for key, value in gf_navbar.items() %}
|
||||
<details id="{{ key }}" , class="accordion">
|
||||
<summary class="accordion-header c-hand left-nav">
|
||||
<span class="nav-item header">{{ key }}</span>
|
||||
</summary>
|
||||
<div class="accordion-body">
|
||||
<ul class="menu menu-nav">
|
||||
{% for subkey, subvalue in value.items() %}
|
||||
<li class="menu-item">
|
||||
<a href="{{ subvalue }}" class="nav-item">{{
|
||||
subkey
|
||||
}}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
<!-- ETC -->
|
||||
</div>
|
||||
</div>
|
||||
<div align="right">
|
||||
<kbd>
|
||||
{{ gf_version }}
|
||||
<a href="{{ gf_repository_url }}">
|
||||
<img src="{{ url_for('static', path='/img/github-inverted.png') }}" class="version-size">
|
||||
</a>
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="off-canvas-overlay" href="#close"></a>
|
55
ghostforge/templates/navigation/top.html
Normal file
55
ghostforge/templates/navigation/top.html
Normal file
|
@ -0,0 +1,55 @@
|
|||
<header class="navbar">
|
||||
<section class="navbar-section">
|
||||
{% if crumbs %}
|
||||
<ul class="breadcrumb">
|
||||
{% for name, url in crumbs %}
|
||||
<li class="breadcrumb-item">
|
||||
{% if url %}
|
||||
<a href="{{ url }}">{{ name }}</a>
|
||||
{% else %}
|
||||
{{ name }}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</section>
|
||||
<section class="navbar-section">
|
||||
<div class="dropdown dropdown-right">
|
||||
<div class="btn-group">
|
||||
<a href="#" class="dropdown-toggle" tabindex="0">
|
||||
<figure class="avatar avatar-lg badge" data-badge="3">
|
||||
<img src="{{ url_for('static', path='/img/default-avatar.png') }}">
|
||||
</figure>
|
||||
</a>
|
||||
<ul class="menu">
|
||||
<li class="menu-item">
|
||||
<div class="tile tile-centered">
|
||||
<div class="tile-icon">
|
||||
<img src="{{ url_for('static', path='/img/default-avatar.png') }}" class="avatar"
|
||||
alt="Avatar">
|
||||
</div>
|
||||
<div class="tile-content">
|
||||
Steve Rogers
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="divider"></li>
|
||||
{% for key, value in avatar_menu.items() %}
|
||||
<li class="menu-item">
|
||||
{% if value is mapping and "quantity" in value and value["quantity"] > 0 %}
|
||||
<div class="menu-badge">
|
||||
<label class="label label-primary">{{ value["quantity"] }}</label>
|
||||
</div>
|
||||
{% set value = value["path"] %}
|
||||
{% endif %}
|
||||
<a href="{{ value }}">
|
||||
{{ key }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</header>
|
62
ghostforge/templates/user.html
Normal file
62
ghostforge/templates/user.html
Normal file
|
@ -0,0 +1,62 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="columns">
|
||||
<div class="column col-12">
|
||||
<h1>Words!</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column col-3"></div>
|
||||
<div class="column col-6">
|
||||
<div class="panel">
|
||||
<div class="panel-header text-center">
|
||||
<figure class="avatar avatar-lg"><img src="../img/avatar-2.png" alt="Avatar"></figure>
|
||||
<div class="panel-title h5 mt-10">Bruce Banner</div>
|
||||
<div class="panel-subtitle">THE HULK</div>
|
||||
</div>
|
||||
<nav class="panel-nav">
|
||||
<ul class="tab tab-block">
|
||||
<li class="tab-item active"><a href="#panels">Profile</a></li>
|
||||
<li class="tab-item"><a href="#panels">Files</a></li>
|
||||
<li class="tab-item"><a href="#panels">Tasks</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="panel-body">
|
||||
<div class="tile tile-centered">
|
||||
<div class="tile-content">
|
||||
<div class="tile-title text-bold">E-mail</div>
|
||||
<div class="tile-subtitle">bruce.banner@hulk.com</div>
|
||||
</div>
|
||||
<div class="tile-action">
|
||||
<button class="btn btn-link btn-action btn-lg tooltip tooltip-left"
|
||||
data-tooltip="Edit E-mail"><i class="icon icon-edit"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile tile-centered">
|
||||
<div class="tile-content">
|
||||
<div class="tile-title text-bold">Skype</div>
|
||||
<div class="tile-subtitle">bruce.banner</div>
|
||||
</div>
|
||||
<div class="tile-action">
|
||||
<button class="btn btn-link btn-action btn-lg"><i class="icon icon-edit"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile tile-centered">
|
||||
<div class="tile-content">
|
||||
<div class="tile-title text-bold">Location</div>
|
||||
<div class="tile-subtitle">Dayton, Ohio</div>
|
||||
</div>
|
||||
<div class="tile-action">
|
||||
<button class="btn btn-link btn-action btn-lg"><i class="icon icon-edit"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<button class="btn btn-primary btn-block">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column col-3"></div>
|
||||
</div>
|
||||
{% endblock content %}
|
15
ghostforge/users.py
Normal file
15
ghostforge/users.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
from fastapi import APIRouter
|
||||
from fastapi import Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from templates import templates
|
||||
|
||||
gf = APIRouter()
|
||||
|
||||
|
||||
@gf.get("/user/{id}", response_class=HTMLResponse)
|
||||
async def get_user(request: Request, id: str):
|
||||
crumbs = [("settings", False), ("users", "/users"), (id, False)]
|
||||
return templates.TemplateResponse(
|
||||
"user.html", {"request": request, "crumbs": crumbs, "user": "test", "id": id, "title": f"User {id}"}
|
||||
)
|
Loading…
Add table
Add a link
Reference in a new issue