Add data tables and some UI work

This commit is contained in:
Darryl Nixon 2023-05-27 22:41:50 -07:00
parent 47931af84f
commit 9dab48a219
45 changed files with 2200 additions and 62 deletions

View file

@ -6,30 +6,15 @@ from fastapi_users_db_sqlmodel import SQLModelUserDatabaseAsync
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlmodel import Field
# from fastapi_users.db import SQLAlchemyBaseUserTableUUID
# from fastapi_users.db import SQLAlchemyUserDatabase
# from sqlalchemy.orm import DeclarativeBase
# from sqlmodel import SQLModel
# class Base(DeclarativeBase):
# pass
# Create a "Base" class for use with fastapi-users-db-sqlmodel and sqlalchemy
class Base(SQLModelBaseUserDB): class Base(SQLModelBaseUserDB):
pass pass
# Create a "User" class for user with fastapi-users-db-sqlmodel
class User(Base, table=True): class User(Base, table=True):
pass username: str = Field(unique=True)
# class User(SQLAlchemyBaseUserTableUUID, Base):
# pass
database_url = ( database_url = (

View file

@ -1,16 +1,27 @@
import uuid
from datetime import datetime from datetime import datetime
from datetime import timezone from operator import truth
from typing import Annotated from typing import Annotated
from typing import Optional from typing import Optional
from fastapi import APIRouter from fastapi import APIRouter
from fastapi import Depends from fastapi import Depends
from fastapi import Form
from fastapi import HTTPException from fastapi import HTTPException
from fastapi import Query from fastapi import Query
from fastapi import Request from fastapi import Request
from sqlalchemy import asc
from sqlalchemy import desc
from sqlalchemy import exists
from sqlalchemy import func
from sqlalchemy import inspect
from sqlalchemy import or_
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy import Text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlmodel import and_
from sqlmodel import AutoString
from sqlmodel import Column from sqlmodel import Column
from sqlmodel import Field from sqlmodel import Field
from sqlmodel import SQLModel from sqlmodel import SQLModel
@ -35,6 +46,7 @@ class Ghost(SQLModel, table=True):
TIMESTAMP(timezone=True), TIMESTAMP(timezone=True),
) )
) )
owner_id: Optional[uuid.UUID] = Field(foreign_key="user.id")
class GhostCreate(Ghost, table=False): class GhostCreate(Ghost, table=False):
@ -49,6 +61,49 @@ class GhostRead(Ghost, table=False):
pass pass
class GhostPermissions(SQLModel, table=True):
id: int = Field(primary_key=True)
user_id: Optional[uuid.UUID] = Field(foreign_key="user.id")
ghost_id: Optional[int] = Field(foreign_key="ghost.id")
can_edit: Optional[bool] = Field()
async def can_view_ghost(
ghost_id: int,
current_user: Annotated[User, Depends(get_current_user())],
session: AsyncSession = Depends(get_session),
):
res = await session.get(Ghost, ghost_id)
if res.owner_id == current_user.id:
return True
res = await session.execute(
select(GhostPermissions).where(
and_(GhostPermissions.ghost_id == ghost_id, GhostPermissions.user_id == current_user.id)
)
)
return truth(res.scalars().first())
async def can_edit_ghost(
ghost_id: int,
current_user: Annotated[User, Depends(get_current_user())],
session: AsyncSession = Depends(get_session),
):
res = await session.get(Ghost, ghost_id)
if res.owner_id == current_user.id:
return True
res = await session.execute(
select(GhostPermissions).where(
and_(
GhostPermissions.ghost_id == ghost_id,
GhostPermissions.user_id == current_user.id,
GhostPermissions.can_edit,
)
)
)
return truth(res.scalars().first())
@gf.get("/ghosts/{ghost_id}") @gf.get("/ghosts/{ghost_id}")
@hj.html_or_json("ghost.html") @hj.html_or_json("ghost.html")
async def read_ghost( async def read_ghost(
@ -56,9 +111,19 @@ async def read_ghost(
current_user: Annotated[User, Depends(get_current_user())], current_user: Annotated[User, Depends(get_current_user())],
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
request: Request = None, request: Request = None,
can_view: bool = Depends(can_view_ghost),
): ):
ghost = await session.get(Ghost, ghost_id) if not can_view:
data = {"crumbs": [("ghosts", "/ghosts"), (ghost.id, False)]} raise HTTPException(status_code=403, detail="You're not authorized to see this ghost")
result = await session.execute(
select(Ghost, User.username.label("owner_username"))
.join(User, Ghost.owner_id == User.id)
.where(Ghost.id == ghost_id)
)
ghost = result.scalars().first()
if not ghost:
raise HTTPException(status_code=404, detail="No ghost with that ID")
data = {"ghost": ghost, "user": current_user, "crumbs": [("ghosts", "/ghosts"), (ghost.id, False)]}
request.state.ghostforge = data | getattr(request.state, "ghostforge", {}) request.state.ghostforge = data | getattr(request.state, "ghostforge", {})
return ghost return ghost
@ -70,18 +135,21 @@ async def update_ghost(
current_user: Annotated[User, Depends(get_current_user())], current_user: Annotated[User, Depends(get_current_user())],
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
request: Request = None, request: Request = None,
can_edit: bool = Depends(can_edit_ghost),
): ):
if not can_edit:
raise HTTPException(status_code=403, detail="You're not authorized to edit this ghost")
db_ghost = await session.get(Ghost, ghost_id) db_ghost = await session.get(Ghost, ghost_id)
if db_ghost is None: if db_ghost is None:
raise HTTPException(status_code=404, detail="No ghost with that ID") raise HTTPException(status_code=404, detail="No ghost with that ID")
for k, v in ghost.dict().items(): for k, v in ghost.dict(exclude_unset=True).items():
setattr(db_ghost, k, v) setattr(db_ghost, k, v)
await session.commit() await session.commit()
await session.refresh(db_ghost) await session.refresh(db_ghost)
return db_ghost return db_ghost
@gf.post("/ghosts") @gf.post("/ghosts/new")
async def create_ghost( async def create_ghost(
ghost: GhostCreate, ghost: GhostCreate,
current_user: Annotated[User, Depends(get_current_user())], current_user: Annotated[User, Depends(get_current_user())],
@ -89,17 +157,86 @@ async def create_ghost(
request: Request = None, request: Request = None,
): ):
new_ghost = Ghost.from_orm(ghost) new_ghost = Ghost.from_orm(ghost)
ghost.birthdate = ghost.birthdate.replace(tzinfo=timezone.utc) new_ghost.owner_id = current_user.id
print(ghost.birthdate)
session.add(new_ghost) session.add(new_ghost)
await session.commit() await session.commit()
await session.refresh(new_ghost) await session.refresh(new_ghost)
return new_ghost return new_ghost
@gf.get("/ghosts") @gf.post("/ghosts")
async def read_users( async def get_ghosts(
offset: int = 0, limit: int = Query(default=100, lte=100), session: Session = Depends(get_session) current_user: Annotated[User, Depends(get_current_user())],
start: int = Form(None, alias="start"),
length: int = Form(None, alias="length"),
search: Optional[str] = Form(None, alias="search[value]"),
order_col: int = Form(0, alias="order[0][column]"),
order_dir: str = Form("asc", alias="order[0][dir]"),
session: AsyncSession = Depends(get_session),
request: Request = None,
): ):
ghosts = await session.execute(select(Ghost).offset(offset).limit(limit)) offset = start
return ghosts.scalars().all() limit = offset + length
# Get the column name based on the order_col integer value
ghost_columns = [c.key for c in inspect(Ghost).c]
order_col_name = ghost_columns[order_col]
# Retrieve filtered ghosts from database
query = (
select(Ghost, User.email.label("owner_email")).offset(offset).limit(limit).join(User, Ghost.owner_id == User.id)
)
permission_filter = or_(
Ghost.owner_id == current_user.id,
exists(
select(GhostPermissions).where(
and_(GhostPermissions.ghost_id == Ghost.id, GhostPermissions.user_id == current_user.id)
)
),
)
count = select(func.count(Ghost.id)).join(User, Ghost.owner_id == User.id).filter(permission_filter)
total_ghosts = (await session.execute(count)).scalar()
query = query.filter(permission_filter)
if search:
conditions = []
for col in ghost_columns:
column_attr = getattr(Ghost, col)
if isinstance(column_attr.type, (AutoString, Text)):
conditions.append(column_attr.ilike(f"%{search}%"))
query = query.where(or_(*conditions))
if order_dir == "asc":
query = query.order_by(asc(getattr(Ghost, order_col_name)))
else:
query = query.order_by(desc(getattr(Ghost, order_col_name)))
ghosts = (await session.execute(query)).all()
return {
"recordsTotal": total_ghosts,
"recordsFiltered": total_ghosts,
"data": ghosts,
}
@gf.get("/ghosts")
@hj.html_or_json("ghosts/ghosts.html")
async def read_users(
current_user: Annotated[User, Depends(get_current_user())],
offset: int = 0,
limit: int = Query(default=100, lte=100),
session: Session = Depends(get_session),
request: Request = None,
):
subquery = (
select(GhostPermissions.ghost_id)
.where(GhostPermissions.user_id == current_user.id)
.union(select(Ghost.id).where(Ghost.owner_id == current_user.id))
.subquery()
)
query = await session.execute(select(Ghost).where(Ghost.id.in_(subquery)).offset(offset).limit(limit))
ghosts = query.scalars().all()
data = {"ghosts": ghosts, "user": current_user, "crumbs": [("ghosts", "/ghosts")]}
request.state.ghostforge = data | getattr(request.state, "ghostforge", {})
return ghosts

View file

@ -52,8 +52,8 @@ class HtmlJson:
result = {"data": result[:]} result = {"data": result[:]}
if hasattr(request.state, "ghostforge"): if hasattr(request.state, "ghostforge"):
if "crumbs" in request.state.ghostforge: for key, value in request.state.ghostforge.items():
result["crumbs"] = request.state.ghostforge["crumbs"] result[key] = value
result.update({"request": request}) result.update({"request": request})
return templates.TemplateResponse(template, result) return templates.TemplateResponse(template, result)

View file

@ -0,0 +1 @@
table.DTCR_clonedTable.dataTable{position:absolute !important;background-color:rgba(255, 255, 255, 0.7);z-index:202}div.DTCR_pointer{width:1px;background-color:#0259c4;z-index:201}

View file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
table.fixedHeader-floating{background-color:white}table.fixedHeader-floating.no-footer{border-bottom-width:0}table.fixedHeader-locked{position:absolute !important;background-color:white}@media print{table.fixedHeader-floating{display:none}}

View file

@ -51,3 +51,13 @@ summary.left-nav::before {
details[open] summary.left-nav::before { details[open] summary.left-nav::before {
transform: rotate(90deg); transform: rotate(90deg);
} }
.ghost_view_title {
padding: 0 .2rem;
padding-bottom: 0.3rem;
}
.ghostforge-container {
padding-right: 5em;
padding-top: 1em;
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
table.dataTable.dtr-inline.collapsed>tbody>tr>td.child,table.dataTable.dtr-inline.collapsed>tbody>tr>th.child,table.dataTable.dtr-inline.collapsed>tbody>tr>td.dataTables_empty{cursor:default !important}table.dataTable.dtr-inline.collapsed>tbody>tr>td.child:before,table.dataTable.dtr-inline.collapsed>tbody>tr>th.child:before,table.dataTable.dtr-inline.collapsed>tbody>tr>td.dataTables_empty:before{display:none !important}table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control,table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control{position:relative;padding-left:30px;cursor:pointer}table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before,table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before{top:50%;left:5px;height:1em;width:1em;margin-top:-9px;display:block;position:absolute;color:white;border:.15em solid white;border-radius:1em;box-shadow:0 0 .2em #444;box-sizing:content-box;text-align:center;text-indent:0 !important;font-family:"Courier New",Courier,monospace;line-height:1em;content:"+";background-color:#31b131}table.dataTable.dtr-inline.collapsed>tbody>tr.parent>td.dtr-control:before,table.dataTable.dtr-inline.collapsed>tbody>tr.parent>th.dtr-control:before{content:"-";background-color:#d33333}table.dataTable.dtr-inline.collapsed.compact>tbody>tr>td.dtr-control,table.dataTable.dtr-inline.collapsed.compact>tbody>tr>th.dtr-control{padding-left:27px}table.dataTable.dtr-inline.collapsed.compact>tbody>tr>td.dtr-control:before,table.dataTable.dtr-inline.collapsed.compact>tbody>tr>th.dtr-control:before{left:4px;height:14px;width:14px;border-radius:14px;line-height:14px;text-indent:3px}table.dataTable.dtr-column>tbody>tr>td.dtr-control,table.dataTable.dtr-column>tbody>tr>th.dtr-control,table.dataTable.dtr-column>tbody>tr>td.control,table.dataTable.dtr-column>tbody>tr>th.control{position:relative;cursor:pointer}table.dataTable.dtr-column>tbody>tr>td.dtr-control:before,table.dataTable.dtr-column>tbody>tr>th.dtr-control:before,table.dataTable.dtr-column>tbody>tr>td.control:before,table.dataTable.dtr-column>tbody>tr>th.control:before{top:50%;left:50%;height:.8em;width:.8em;margin-top:-0.5em;margin-left:-0.5em;display:block;position:absolute;color:white;border:.15em solid white;border-radius:1em;box-shadow:0 0 .2em #444;box-sizing:content-box;text-align:center;text-indent:0 !important;font-family:"Courier New",Courier,monospace;line-height:1em;content:"+";background-color:#31b131}table.dataTable.dtr-column>tbody>tr.parent td.dtr-control:before,table.dataTable.dtr-column>tbody>tr.parent th.dtr-control:before,table.dataTable.dtr-column>tbody>tr.parent td.control:before,table.dataTable.dtr-column>tbody>tr.parent th.control:before{content:"-";background-color:#d33333}table.dataTable>tbody>tr.child{padding:.5em 1em}table.dataTable>tbody>tr.child:hover{background:transparent !important}table.dataTable>tbody>tr.child ul.dtr-details{display:inline-block;list-style-type:none;margin:0;padding:0}table.dataTable>tbody>tr.child ul.dtr-details>li{border-bottom:1px solid #efefef;padding:.5em 0}table.dataTable>tbody>tr.child ul.dtr-details>li:first-child{padding-top:0}table.dataTable>tbody>tr.child ul.dtr-details>li:last-child{border-bottom:none}table.dataTable>tbody>tr.child span.dtr-title{display:inline-block;min-width:75px;font-weight:bold}div.dtr-modal{position:fixed;box-sizing:border-box;top:0;left:0;height:100%;width:100%;z-index:100;padding:10em 1em}div.dtr-modal div.dtr-modal-display{position:absolute;top:0;left:0;bottom:0;right:0;width:50%;height:50%;overflow:auto;margin:auto;z-index:102;overflow:auto;background-color:#f5f5f7;border:1px solid black;border-radius:.5em;box-shadow:0 12px 30px rgba(0, 0, 0, 0.6)}div.dtr-modal div.dtr-modal-content{position:relative;padding:1em}div.dtr-modal div.dtr-modal-close{position:absolute;top:6px;right:6px;width:22px;height:22px;border:1px solid #eaeaea;background-color:#f9f9f9;text-align:center;border-radius:3px;cursor:pointer;z-index:12}div.dtr-modal div.dtr-modal-close:hover{background-color:#eaeaea}div.dtr-modal div.dtr-modal-background{position:fixed;top:0;left:0;right:0;bottom:0;z-index:101;background:rgba(0, 0, 0, 0.6)}@media screen and (max-width: 767px){div.dtr-modal div.dtr-modal-display{width:95%}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
div.dtsr-confirmation,div.dtsr-creation{position:fixed;top:20%;left:50%;width:500px;background-color:white;margin-left:-250px;border-radius:6px;box-shadow:0 0 5px #555;border:2px solid #444;z-index:2003;box-sizing:border-box;padding:1em}div.dtsr-confirmation div.dtsr-confirmation-title-row,div.dtsr-creation div.dtsr-confirmation-title-row{text-align:left}div.dtsr-confirmation div.dtsr-confirmation-title-row h2,div.dtsr-creation div.dtsr-confirmation-title-row h2{border-bottom:0px;margin-top:0px;padding-top:0px}div.dtsr-confirmation div.dtsr-confirmation-text,div.dtsr-creation div.dtsr-confirmation-text{text-align:center}div.dtsr-confirmation div.dtsr-confirmation-buttons,div.dtsr-creation div.dtsr-confirmation-buttons{text-align:right;margin-top:1em}div.dtsr-confirmation div.dtsr-confirmation-buttons button.dtsr-confirmation-button,div.dtsr-creation div.dtsr-confirmation-buttons button.dtsr-confirmation-button{margin:0px}div.dtsr-confirmation div.dtsr-creation-text,div.dtsr-creation div.dtsr-creation-text{text-align:left;padding:0px;border:none}div.dtsr-confirmation div.dtsr-creation-text span,div.dtsr-creation div.dtsr-creation-text span{font-size:20px}div.dtsr-confirmation div.dtsr-creation-form div.dtsr-left,div.dtsr-confirmation div.dtsr-creation-form div.dtsr-right,div.dtsr-creation div.dtsr-creation-form div.dtsr-left,div.dtsr-creation div.dtsr-creation-form div.dtsr-right{display:inline-block;width:50%}div.dtsr-confirmation div.dtsr-creation-form div.dtsr-left,div.dtsr-creation div.dtsr-creation-form div.dtsr-left{text-align:right}div.dtsr-confirmation div.dtsr-creation-form div.dtsr-right,div.dtsr-confirmation div.dtsr-creation-form div.dtsr-name-row,div.dtsr-creation div.dtsr-creation-form div.dtsr-right,div.dtsr-creation div.dtsr-creation-form div.dtsr-name-row{text-align:left}div.dtsr-confirmation div.dtsr-creation-form div.dtsr-form-row label.dtsr-name-label,div.dtsr-creation div.dtsr-creation-form div.dtsr-form-row label.dtsr-name-label{width:33.3%;display:inline-block;text-align:right;padding-right:15px;padding-left:15px}div.dtsr-confirmation div.dtsr-creation-form div.dtsr-form-row input.dtsr-name-input,div.dtsr-creation div.dtsr-creation-form div.dtsr-form-row input.dtsr-name-input{width:66.6%;display:inline-block}div.dtsr-confirmation div.dtsr-creation-form div.dtsr-form-row input.dtsr-check-box,div.dtsr-creation div.dtsr-creation-form div.dtsr-form-row input.dtsr-check-box{margin-left:33.3%;margin-right:14px;display:inline-block}div.dtsr-confirmation div.dtsr-creation-form div.dtsr-form-row label.dtsr-toggle-title,div.dtsr-creation div.dtsr-creation-form div.dtsr-form-row label.dtsr-toggle-title{margin-right:-33.3%}div.dtsr-confirmation div.dtsr-confirmation-text,div.dtsr-creation div.dtsr-confirmation-text{text-align:left}div.dtsr-confirmation div.dtsr-confirmation-text label.dtsr-name-label,div.dtsr-creation div.dtsr-confirmation-text label.dtsr-name-label{width:auto;display:inline-block;text-align:right;padding-right:15px}div.dtsr-confirmation div.dtsr-confirmation-text input.dtsr-name-input,div.dtsr-creation div.dtsr-confirmation-text input.dtsr-name-input{width:66.6%;display:inline-block}div.dtsr-confirmation div.dtsr-confirmation-text input.dtsr-check-box,div.dtsr-creation div.dtsr-confirmation-text input.dtsr-check-box{margin-left:33.3%;margin-right:14px;display:inline-block}div.dtsr-confirmation div.dtsr-modal-foot,div.dtsr-creation div.dtsr-modal-foot{text-align:right;padding-top:10px}div.dtsr-confirmation span.dtsr-modal-error,div.dtsr-creation span.dtsr-modal-error{color:red;font-size:.9em}div.dtsr-creation{top:10%}div.dtsr-form-row{padding:10px}div.dtsr-check-row{padding-top:0px}div.dtsr-creation-text{padding:10px}div.dtsr-popover-close{position:absolute;top:10px;right:10px;width:22px;height:22px;border:1px solid #eaeaea;background-color:#f9f9f9;text-align:center;border-radius:3px;cursor:pointer;z-index:12}div.dtsr-background{z-index:2002;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0, 0, 0, 0.7);background:radial-gradient(ellipse farthest-corner at center, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%)}div.dt-button-collection h3{text-align:center;margin-top:4px;margin-bottom:8px;font-size:1.5em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}div.dt-button-collection span.dtsr-emptyStates{border-radius:5px;display:inline-block;line-height:1.6em;white-space:nowrap;text-align:center;vertical-align:middle;width:100%;padding-bottom:7px;padding-top:3px}div.dt-button-collection h3{font-size:1.1em}div.dtsr-creation-form div.dtsr-form-row input.dtsr-name-input{width:57% !important;padding:5px 4px;border:1px solid #aaa;border-radius:3px}div.dtsr-creation-form div.dtsr-form-row input.dtsr-check-box{margin-left:calc(33.3% + 30px) !important}div.dtsr-creation-form div.dtsr-form-row label.dtsr-toggle-title{margin-right:calc(-33.3% - 30px) !important}

View file

@ -0,0 +1,4 @@
/*! DataTables styling wrapper for ColReorder
* © SpryMedia Ltd - datatables.net/license
*/
!function(n){var o,d;"function"==typeof define&&define.amd?define(["jquery","datatables.net-dt","datatables.net-colreorder"],function(e){return n(e,window,document)}):"object"==typeof exports?(o=require("jquery"),d=function(e,t){t.fn.dataTable||require("datatables.net-dt")(e,t),t.fn.dataTable.ColReorder||require("datatables.net-colreorder")(e,t)},"undefined"!=typeof window?module.exports=function(e,t){return e=e||window,t=t||o(e),d(e,t),n(t,0,e.document)}:(d(window,o),module.exports=n(o,window,window.document))):n(jQuery,window,document)}(function(e,t,n,o){"use strict";return e.fn.dataTable});

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,4 @@
/*! DataTables styling integration
* ©2018 SpryMedia Ltd - datatables.net/license
*/
!function(t){var o,d;"function"==typeof define&&define.amd?define(["jquery","datatables.net"],function(e){return t(e,window,document)}):"object"==typeof exports?(o=require("jquery"),d=function(e,n){n.fn.dataTable||require("datatables.net")(e,n)},"undefined"!=typeof window?module.exports=function(e,n){return e=e||window,n=n||o(e),d(e,n),t(n,0,e.document)}:(d(window,o),module.exports=t(o,window,window.document))):t(jQuery,window,document)}(function(e,n,t,o){"use strict";return e.fn.dataTable});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,4 @@
/*! DataTables styling wrapper for FixedHeader
* © SpryMedia Ltd - datatables.net/license
*/
!function(n){var d,a;"function"==typeof define&&define.amd?define(["jquery","datatables.net-dt","datatables.net-fixedheader"],function(e){return n(e,window,document)}):"object"==typeof exports?(d=require("jquery"),a=function(e,t){t.fn.dataTable||require("datatables.net-dt")(e,t),t.fn.dataTable.FixedHeader||require("datatables.net-fixedheader")(e,t)},"undefined"!=typeof window?module.exports=function(e,t){return e=e||window,t=t||d(e),a(e,t),n(t,0,e.document)}:(a(window,d),module.exports=n(d,window,window.document))):n(jQuery,window,document)}(function(e,t,n,d){"use strict";return e.fn.dataTable});

View file

@ -0,0 +1,16 @@
function switchTab(tab) {
var i;
var x = document.getElementsByClassName("profile_tab");
var a = document.getElementsByClassName("profile_tab_link");
for (i = 0; i < x.length; i++) {
x[i].style.display = "none";
}
for (i = 0; i < a.length; i++) {
if (a[i].id.endsWith(tab)) {
a[i].classList.add("active");
} else {
a[i].classList.remove("active");
}
}
document.getElementById(tab).style.display = "block";
}

View file

@ -1,5 +1,4 @@
// Save and restore navigation menu state from local storage. // Save and restore navigation menu state from local storage.
function saveMenuState() { function saveMenuState() {
var leftNavigation = document.getElementById("left-navigation"); var leftNavigation = document.getElementById("left-navigation");
if (!leftNavigation) { if (!leftNavigation) {
@ -77,23 +76,41 @@ function modal_focus(modal_name) {
// Hotkeys // Hotkeys
document.addEventListener("keydown", function (event) { document.addEventListener("keydown", function (event) {
if (event.key === "Escape" || event.keyCode === 27) {
window.location.hash = "#";
}
// Ctrl/Cmd+G for search modal // Ctrl/Cmd+G for search modal
if (event.key == "g") { else if (event.key == "g") {
const nav_search_button = document.getElementById("nav_search_button"); const nav_search_button = document.getElementById("nav_search_button");
const search_box = document.getElementById("search_string"); const search_box = document.getElementById("search_string");
console.log(search_box);
if (event.ctrlKey) { if (event.ctrlKey) {
event.preventDefault(); // prevent default Ctrl+G behavior (find in page) event.preventDefault();
nav_search_button.click(); nav_search_button.click();
search_box.focus(); search_box.focus();
} }
// Add support for Apple+G on Macs
if (event.metaKey) { if (event.metaKey) {
event.preventDefault(); // prevent default Apple+G behavior (bookmark) event.preventDefault();
nav_search_button.click(); nav_search_button.click();
search_box.focus(); search_box.focus();
} }
} }
// Ctrl/Cmd+N for new ghost modal
else if (event.key == "n") {
const nav_new_button = document.getElementById("nav_new_button");
const new_ghost_box = document.getElementById("new_firstname");
if (event.ctrlKey) {
event.preventDefault();
nav_new_button.click();
new_ghost_box.focus();
}
if (event.metaKey) {
event.preventDefault();
nav_new_button.click();
new_ghost_box.focus();
}
}
}); });
// Run various onload/unload functions since script is loaded before elements are // Run various onload/unload functions since script is loaded before elements are

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,4 @@
/*! DataTables styling wrapper for Responsive
* © SpryMedia Ltd - datatables.net/license
*/
!function(t){var o,d;"function"==typeof define&&define.amd?define(["jquery","datatables.net-dt","datatables.net-responsive"],function(e){return t(e,window,document)}):"object"==typeof exports?(o=require("jquery"),d=function(e,n){n.fn.dataTable||require("datatables.net-dt")(e,n),n.fn.dataTable.Responsive||require("datatables.net-responsive")(e,n)},"undefined"!=typeof window?module.exports=function(e,n){return e=e||window,n=n||o(e),d(e,n),t(n,0,e.document)}:(d(window,o),module.exports=t(o,window,window.document))):t(jQuery,window,document)}(function(e,n,t,o){"use strict";return e.fn.dataTable});

View file

@ -0,0 +1,4 @@
/*! DataTables integration for DataTables' SearchBuilder
* © SpryMedia Ltd - datatables.net/license
*/
!function(n){var d,a;"function"==typeof define&&define.amd?define(["jquery","datatables.net-dt","datatables.net-searchbuilder"],function(e){return n(e,window,document)}):"object"==typeof exports?(d=require("jquery"),a=function(e,t){t.fn.dataTable||require("datatables.net-dt")(e,t),t.fn.dataTable.SearchBuilder||require("datatables.net-searchbuilder")(e,t)},"undefined"!=typeof window?module.exports=function(e,t){return e=e||window,t=t||d(e),a(e,t),n(t,0,e.document)}:(a(window,d),module.exports=n(d,window,window.document))):n(jQuery,window,document)}(function(e,t,n,d){"use strict";return e.fn.dataTable});

View file

@ -0,0 +1,4 @@
/*! Bootstrap integration for DataTables' SearchPanes
* © SpryMedia Ltd - datatables.net/license
*/
!function(t){var a,d;"function"==typeof define&&define.amd?define(["jquery","datatables.net-dt","datatables.net-searchpanes"],function(e){return t(e,window,document)}):"object"==typeof exports?(a=require("jquery"),d=function(e,n){n.fn.dataTable||require("datatables.net-dt")(e,n),n.fn.dataTable.SearchPanes||require("datatables.net-searchpanes")(e,n)},"undefined"!=typeof window?module.exports=function(e,n){return e=e||window,n=n||a(e),d(e,n),t(n,0,e.document)}:(d(window,a),module.exports=t(a,window,window.document))):t(jQuery,window,document)}(function(e,n,t,a){"use strict";return e.fn.dataTable});

View file

@ -0,0 +1,4 @@
/*! Bootstrap integration for DataTables' StateRestore
* © SpryMedia Ltd - datatables.net/license
*/
!function(n){var o,d;"function"==typeof define&&define.amd?define(["jquery","datatables.net-dt","datatables.net-staterestore"],function(e){return n(e,window,document)}):"object"==typeof exports?(o=require("jquery"),d=function(e,t){t.fn.dataTable||require("datatables.net-dt")(e,t),t.fn.dataTable.StateRestore||require("datatables.net-staterestore")(e,t)},"undefined"!=typeof window?module.exports=function(e,t){return e=e||window,t=t||o(e),d(e,t),n(t,0,e.document)}:(d(window,o),module.exports=n(o,window,window.document))):n(jQuery,window,document)}(function(e,t,n,o){"use strict";return e.fn.dataTable});

View file

@ -15,12 +15,7 @@ templates.env.globals["gf_repository_url"] = "https://github.com/DarrylNixon/gho
# Same, but build the navbar from an ordered dictionary for centralization. # Same, but build the navbar from an ordered dictionary for centralization.
# Since 3.7 (we require >= 3.9), dicts are guaranteed ordered as inserted. # Since 3.7 (we require >= 3.9), dicts are guaranteed ordered as inserted.
templates.env.globals["gf_navbar"] = { templates.env.globals["gf_navbar"] = {
"Ghosts": { "Ghosts": {"Dashboard": "/dashboard", "Browse": "/ghosts"},
"Dashboard": "/dashboard",
"New": "/ghosts/new",
"Active": "/ghosts",
"Archived": "/ghosts/archive",
},
"Research": { "Research": {
"Guidebook": "/guidebook", "Guidebook": "/guidebook",
"Cheat Sheet": "/cheatsheet", "Cheat Sheet": "/cheatsheet",

View file

@ -10,20 +10,24 @@
<link href="{{ url_for('static', path='/css/spectre-exp.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/spectre-icons.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/ghostforge.css') }}" rel="stylesheet"> <link href="{{ url_for('static', path='/css/ghostforge.css') }}" rel="stylesheet">
{% block css %}{% endblock css %}
<script src="{{ url_for('static', path='/js/ghostforge.js') }}"></script> <script src="{{ url_for('static', path='/js/ghostforge.js') }}"></script>
{% block js %}{% endblock js %}
</head> </head>
<body> <body>
<div class="off-canvas off-canvas-sidebar-show d-flex"> <div class="off-canvas off-canvas-sidebar-show d-flex">
{% include "navigation/side.html" %} {% include "navigation/side.html" %}
<div class="off-canvas-content"> <div class="off-canvas-content">
<div class="container" style="padding-right: 5em; padding-top: 1em;"> <div class="container ghostforge-container">
{% include "navigation/top.html" %} {% include "navigation/top.html" %}
{% block content %} {% block content %}
{% endblock content %} {% endblock content %}
</div> </div>
</div> </div>
</div> </div>
{% include "modals/modals.html" %}
</body> </body>
{% block bottomjs %}{% endblock bottomjs %}
</html> </html>

View file

@ -3,14 +3,91 @@
{% block content %} {% block content %}
<div class="columns"> <div class="columns">
<div class="column col-12"> <div class="column col-12">
<h1>Words!</h1> <h1><span class="text-light bg-dark ghost_view_title">{% block title %}
{{
ghost.first_name }} {{
ghost.middle_name
}} {{
ghost.last_name }}{% endblock title %}</span>
</h1>
<h5>[age] year old American Male</h5>
</div> </div>
</div> </div>
<div class="columns"> <div class="columns">
<div class="column col-3"></div> <div class="column col-8">
<div class="column col-6"> {% include "ghosts/tabs/tabs.html" %}
<h5>More words.</h5> <div class="card">
<div class="card-body">
<button class="btn btn-primary float-right">Save Changes</button>
</div>
</div>
</div>
<div class="column col-4">
<div class="panel">
<div class="panel-header">
<div class="panel-title h6">Notes</div>
</div>
<div class="panel-body">
<div class="tile">
<div class="tile-icon">
<figure class="avatar"><img src="../img/avatar-1.png" alt="Avatar"></figure>
</div>
<div class="tile-content">
<p class="tile-title text-bold">Thor Odinson</p>
<p class="tile-subtitle">Earth's Mightiest Heroes joined forces to take on threats that were too
big for any one hero to tackle...</p>
</div>
</div>
<div class="tile">
<div class="tile-icon">
<figure class="avatar"><img src="../img/avatar-2.png" alt="Avatar"></figure>
</div>
<div class="tile-content">
<p class="tile-title text-bold">Bruce Banner</p>
<p class="tile-subtitle">The Strategic Homeland Intervention, Enforcement, and Logistics
Division...</p>
</div>
</div>
<div class="tile">
<div class="tile-icon">
<figure class="avatar" data-initial="TS"></figure>
</div>
<div class="tile-content">
<p class="tile-title text-bold">Tony Stark</p>
<p class="tile-subtitle">Earth's Mightiest Heroes joined forces to take on threats that were too
big for any one hero to tackle...</p>
</div>
</div>
<div class="tile">
<div class="tile-icon">
<figure class="avatar"><img src="../img/avatar-4.png" alt="Avatar"></figure>
</div>
<div class="tile-content">
<p class="tile-title text-bold">Steve Rogers</p>
<p class="tile-subtitle">The Strategic Homeland Intervention, Enforcement, and Logistics
Division...</p>
</div>
</div>
<div class="tile">
<div class="tile-icon">
<figure class="avatar"><img src="../img/avatar-3.png" alt="Avatar"></figure>
</div>
<div class="tile-content">
<p class="tile-title text-bold">Natasha Romanoff</p>
<p class="tile-subtitle">Earth's Mightiest Heroes joined forces to take on threats that were too
big for any one hero to tackle...</p>
</div>
</div>
</div>
<div class="panel-footer">
<div class="input-group">
<input class="form-input" type="text" placeholder="Hello">
<button class="btn btn-primary input-group-btn">Send</button>
</div>
</div>
</div>
</div> </div>
<div class="column col-3"></div>
</div> </div>
<script src="{{ url_for('static', path='/js/ghost.js') }}"></script>
{% endblock content %} {% endblock content %}

View file

@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block js %}
<script src="{{ url_for('static', path='/js/jquery-3.7.0.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/colReorder.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.searchPanes.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.colReorder.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.stateRestore.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/responsive.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/fixedHeader.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/searchBuilder.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.fixedHeader.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/searchPanes.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.responsive.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/stateRestore.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.searchBuilder.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/jquery.dataTables.min.js') }}"></script>
{% endblock js %}
{% block css %}
<link href="{{ url_for('static', path='/css/datatables.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/colReorder.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/dataTables.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/fixedHeader.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/jquery.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/responsive.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/colReorder.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/stateRestore.datatables.min.css') }}" rel="stylesheet">
{% endblock css %}
{% block content %}
<div class="columns">
<div class="column col-12">
<h1>Browse Ghosts</h1>
</div>
</div>
<div class="columns">
<div class="column col-12">
<table id="ghosts-table">
<thead>
<tr>
<th>ID</th>
<th>First</th>
<th>Middle</th>
<th>Last</th>
<th>Birthdate</th>
<th>Owner</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
{% endblock content %}
{% block bottomjs %}
<script>
$(document).ready(function () {
$('#ghosts-table').DataTable({
"processing": true,
"serverSide": true,
"ajax": {
"url": "/ghosts",
"type": "POST",
"dataType": "json"
},
"columns": [
{
"data": "Ghost.id",
"render": function (data, type, row, meta) {
return '<a href="/ghosts/' + data + '">' + data + '</a>';
}
},
{ "data": "Ghost.first_name" },
{ "data": "Ghost.middle_name" },
{ "data": "Ghost.last_name" },
{
"data": "Ghost.birthdate",
"render": function (data, type, row, meta) {
var date = new Date(data);
var day = date.getDate().toString().padStart(2, '0');
var month = (date.getMonth() + 1).toString().padStart(2, '0');
var year = date.getFullYear().toString();
return month + '/' + day + '/' + year;
}
},
{ "data": "owner_email" },
]
});
});
</script>
{% endblock bottomjs %}

View file

@ -0,0 +1,27 @@
<h5>Identity</h5>
<div class="form-group">
<label class="form-label form-inline">First Name
<input class="form-input" type="text" id="firstname" placeholder="First">
</label>
<label class="form-label form-inline">Middle Name
<input class="form-input" type="text" id="middlename" placeholder="Middle">
</label>
<label class="form-label form-inline">Last Name
<input class="form-input" type="text" id="lastname" placeholder="Last">
</label>
</div>
<div class="form-group">
<label class="form-label">Ethnicities
<div class="form-group">
<select class="form-select form-inline" multiple>
<option>German</option>
<option>Irish</option>
<option>Mexican</option>
<option>Canadian</option>
<option>American</option>
<option>British</option>
<option>Chinese</option>
</select>
</div>
</label>
</div>

View file

@ -0,0 +1,10 @@
<div id="Persona" class="profile_tab">
<div class="card-header">
<div class="card-title h5">Persona</div>
<div class="card-subtitle text-gray">"i don't even know who you are"</div>
</div>
<div class="card-body">
{% include "ghosts/tabs/persona/top.html" %}
{% include "ghosts/tabs/persona/identity.html" %}
</div>
</div>

View file

@ -0,0 +1,20 @@
<div class="form-group">
<label class="form-label">
Owner
<input class="form-input" type="text" id="owner" placeholder="Owner GUID" value="{{ owner_username }}">
</label>
</div>
<div class="popover popover-top">
<h6>Completion</h6>
<div class="popover-container">
<div class="card">
<div class="card-body">
<small><strong>Completion</strong> measures how many "important" fields are filled
to backstop this
persona. It doesn't check values for efficacy, just that they have
values.</small>
</div>
</div>
</div>
</div>
<meter class="meter" value="60" min="0" max="100" low="30" high="80" style="margin-bottom: 1em;"></meter>

View file

@ -0,0 +1,43 @@
<ul class="tab tab-block">
<li class="tab-item">
<a href="#" onclick="switchTab('Persona')" class="profile_tab_link active" id="TabPersona">Persona</a>
</li>
<li class="tab-item">
<a href="#" onclick="switchTab('Credentials')" class="profile_tab_link" id="TabCredentials">Credentials</a>
</li>
<li class="tab-item">
<a href="#" onclick="switchTab('Acquisitions')" class="profile_tab_link" id="TabAcquisitions">Acquisitions</a>
</li>
<li class="tab-item">
<a href="#" onclick="switchTab('Meta')" class="profile_tab_link" id="TabMeta">Meta</a>
</li>
</ul>
<form>
<div class="card">
{% include "ghosts/tabs/persona/persona.html" %}
<div id="Credentials" class="profile_tab" style="display:none">
<div class="card-header">
<div class="card-title h5">Credentials</div>
<div class="card-subtitle text-gray">"my voice is my passport, verify me"</div>
</div>
<div class="card-body">Credentials are very important</div>
</div>
<div id="Acquisitions" class="profile_tab" style="display:none">
<div class="card-header">
<div class="card-title h5">Acquisitions</div>
<div class="card-subtitle text-gray">"if you can't hack it, buy it"</div>
</div>
<div class="card-body">Acquisitions are very important</div>
</div>
<div id="History" class="profile_tab" style="display:none">
<div class="card-header">
<div class="card-title h5">Meta</div>
<div class="card-subtitle text-gray">"now where was i..."</div>
</div>
<div class="card-body">History is very important</div>
</div>
</div>
</form>

View file

@ -0,0 +1,2 @@
{% include "modals/search.html" %}
{% include "modals/new_ghost.html" %}

View file

@ -0,0 +1,28 @@
<div class="modal modal-sm" id="new-ghost-modal"><a class="modal-overlay" href="#" aria-label="Close"></a>
<div class="modal-container" role="document">
<div class="modal-header"><a class="btn btn-clear float-right" href="#" aria-label="Close"></a>
<div class="modal-title h5">New Ghost</div>
</div>
<div class="modal-body">
<div class="content">
<form>
<div class="form-group">
<label class="form-label">
<h6>First Name</h6>
<input class="form-input" id="new_firstname" type="text" placeholder="First">
</label>
<label class="form-label">
<h6>Last Name</h6>
<input class="form-input" id="new_lastname" type="text" placeholder="Last">
</label>
</div>
</form>
</div>
</div>
<div class="modal-footer">
<button class="btn">Randomize</button>
<button class="btn btn-primary">Create Ghost</button>
<a class="btn btn-link" href="#modals-sizes" aria-label="Close">Close</a>
</div>
</div>
</div>

View file

@ -1,9 +1,3 @@
<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 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-container" role="document">
<div class="modal-header"><a class="btn btn-clear float-right" href="#modals-sizes" aria-label="Close"></a> <div class="modal-header"><a class="btn btn-clear float-right" href="#modals-sizes" aria-label="Close"></a>

View file

@ -1,10 +1,10 @@
<a class="off-canvas-toggle btn btn-primary btn-action" href="#left-navigation"><i class="icon icon-menu"></i></a> <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 id="left-navigation" class="off-canvas-sidebar">
<div class="navigation-menu"> <div class="navigation-menu">
<a href="/" class="logo"> <a href="/" class="logo" style="display: block;">
<img src="{{ url_for('static', path='/img/ghostforge-sidebar.png') }}" class="brand"> <img src="{{ url_for('static', path='/img/ghostforge-sidebar.png') }}" class="brand">
</a> </a>
<div>{% include "modals/search.html" %}</div> {% include "navigation/side_buttons.html" %}
<div class="nav-menus"> <div class="nav-menus">
{% for key, value in gf_navbar.items() %} {% for key, value in gf_navbar.items() %}
<details id="{{ key }}" , class="accordion"> <details id="{{ key }}" , class="accordion">

View file

@ -0,0 +1,14 @@
<div class="text-center">
<div style="display: inline-block;">
<a class="btn btn-primary btn-sm" href="#search-modal" id="nav_search_button">
<i class="icon icon-search"></i>
⌘-g
</a>
</div>
<div style="display: inline-block;">
<a class="btn btn-primary btn-sm" href="#new-ghost-modal" id="nav_new_button">
<i class="icon icon-plus"></i>
⌘-n
</a>
</div>
</div>

View file

@ -2,7 +2,7 @@
<section class="navbar-section"> <section class="navbar-section">
{% if crumbs %} {% if crumbs %}
<ul class="breadcrumb"> <ul class="breadcrumb">
<li class="breadcrumb-item"><kbd>{{ user.email.split('@')[0] }}</kbd></li> <li class="breadcrumb-item"><kbd>{{ user.username }}</kbd></li>
{% for name, url in crumbs %} {% for name, url in crumbs %}
<li class="breadcrumb-item"> <li class="breadcrumb-item">
{% if url %} {% if url %}
@ -31,7 +31,7 @@
alt="Avatar"> alt="Avatar">
</div> </div>
<div class="tile-content"> <div class="tile-content">
{{ user.email }} {{ user.username }}
</div> </div>
</div> </div>
</li> </li>

View file

@ -25,15 +25,15 @@ gf = APIRouter()
class UserRead(schemas.BaseUser[uuid.UUID]): class UserRead(schemas.BaseUser[uuid.UUID]):
pass username: str
class UserCreate(schemas.BaseUserCreate): class UserCreate(schemas.BaseUserCreate):
pass username: str
class UserUpdate(schemas.BaseUserUpdate): class UserUpdate(schemas.BaseUserUpdate):
pass username: str
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):