diff --git a/back/app/db/models/oauth.py b/back/app/db/models/oauth.py index 0faac11..1c1645c 100644 --- a/back/app/db/models/oauth.py +++ b/back/app/db/models/oauth.py @@ -12,7 +12,7 @@ class OAuthToken(Base): access_token = Column(String(128), nullable=False) refresh_token = Column(String(128), nullable=True) - service_id = Column(Integer, ForeignKey("service.id"), nullable=False) + service = Column(String, nullable=False) scope = Column(String(512), nullable=True) expires_at = Column(DateTime, nullable=True) diff --git a/back/app/db/models/user.py b/back/app/db/models/user.py index 741749d..ab092ca 100644 --- a/back/app/db/models/user.py +++ b/back/app/db/models/user.py @@ -30,5 +30,5 @@ class User(Base): back_populates="user", cascade="all, delete-orphan", passive_deletes=True, - lazy="selectin" + lazy="selectin", ) diff --git a/back/app/routes/__init__.py b/back/app/routes/__init__.py index dbfe8e8..b54e68d 100644 --- a/back/app/routes/__init__.py +++ b/back/app/routes/__init__.py @@ -4,6 +4,7 @@ routers = [] providers = ( + "about", "auth", "hello", "workflow", @@ -19,10 +20,12 @@ try: mod = importlib.import_module(f".{mod_name}", __package__) - assert hasattr(mod, "router"), \ - f"Module {mod.__name__} is missing 'router' attribute" - assert isinstance(mod.router, APIRouter), \ - f"'router' in module {mod.__name__} is not an APIRouter instance" + assert hasattr( + mod, "router" + ), f"Module {mod.__name__} is missing 'router' attribute" + assert isinstance( + mod.router, APIRouter + ), f"'router' in module {mod.__name__} is not an APIRouter instance" print( "Registering router from module:" f" {mod.__name__} with prefix: {mod.router.prefix}" diff --git a/back/app/routes/about.py b/back/app/routes/about.py new file mode 100644 index 0000000..f0cb029 --- /dev/null +++ b/back/app/routes/about.py @@ -0,0 +1,33 @@ +import time + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +from .oauth_base import OAuthProvider + +router = APIRouter(prefix="") + + +@router.get("/about.json") +async def about_json(request: Request): + client = request.client + client_ip = client.host if client else "unknown" + services = OAuthProvider.services.keys() + return JSONResponse( + { + "client": { + "host": client_ip, + }, + "server": { + "current_time": int(time.time()), + "services": [ + { + "name": service_name, + "actions": [], + "reactions": [], + } + for service_name in services + ], + }, + } + ) diff --git a/back/app/routes/auth.py b/back/app/routes/auth.py index 6bdd8cb..162ba99 100644 --- a/back/app/routes/auth.py +++ b/back/app/routes/auth.py @@ -64,7 +64,7 @@ async def login_user( }, ) async def get_me( - current_user = Depends(get_current_user), + current_user=Depends(get_current_user), ) -> UserSchema: connected_services = {token.service: True for token in current_user.tokens} @@ -73,6 +73,7 @@ async def get_me( services=connected_services, ) + @router.patch( "/credentials", response_model=UserBase, diff --git a/back/app/routes/caldav/google.py b/back/app/routes/caldav/google.py index d24bc03..a427d11 100644 --- a/back/app/routes/caldav/google.py +++ b/back/app/routes/caldav/google.py @@ -47,7 +47,7 @@ class Config(BaseModel): provider = OAuthProvider( package=__package__, config_model=Config, - icon=(pathlib.Path(__file__).parent / "icon.svg").read_text() + icon=(pathlib.Path(__file__).parent / "icon.svg").read_text(), ) diff --git a/back/app/routes/discord/discord.py b/back/app/routes/discord/discord.py index 7bff75c..39df74c 100644 --- a/back/app/routes/discord/discord.py +++ b/back/app/routes/discord/discord.py @@ -33,7 +33,7 @@ class Config(BaseModel): provider = OAuthProvider( package=__package__, config_model=Config, - icon=(pathlib.Path(__file__).parent / "icon.svg").read_text() + icon=(pathlib.Path(__file__).parent / "icon.svg").read_text(), ) diff --git a/back/app/routes/gmail/gmail.py b/back/app/routes/gmail/gmail.py index 15ef556..b2eb919 100644 --- a/back/app/routes/gmail/gmail.py +++ b/back/app/routes/gmail/gmail.py @@ -49,7 +49,7 @@ class Config(BaseModel): provider = OAuthProvider( package=__package__, config_model=Config, - icon=(pathlib.Path(__file__).parent / "icon.svg").read_text() + icon=(pathlib.Path(__file__).parent / "icon.svg").read_text(), ) diff --git a/back/app/routes/oauth_base.py b/back/app/routes/oauth_base.py index 32b5639..cb12fc6 100644 --- a/back/app/routes/oauth_base.py +++ b/back/app/routes/oauth_base.py @@ -44,12 +44,7 @@ class OAuthProvider: services = {} - def __init__( - self, - icon: str, - package: str | None, - config_model: Any - ): + def __init__(self, icon: str, package: str | None, config_model: Any): assert package is not None, "Package name must be provided" *_, service_name = package.split(".") @@ -172,7 +167,7 @@ async def auth(self, code: str, state: str, db: AsyncSession): tokens = resp.json() token = OAuthToken( - user_id=user.id, + owner_id=user.id, service=self.cfg.service, access_token=tokens.get("access_token"), refresh_token=tokens.get("refresh_token"), @@ -294,6 +289,7 @@ async def me(self, user: User, db: AsyncSession): router = APIRouter(prefix="/services", tags=["services"]) + @router.get("", response_model=dict[str, str]) async def get_service_list(): return OAuthProvider.services diff --git a/back/app/routes/spotify/spotify.py b/back/app/routes/spotify/spotify.py index 5e79779..3b30e25 100644 --- a/back/app/routes/spotify/spotify.py +++ b/back/app/routes/spotify/spotify.py @@ -29,7 +29,7 @@ class Config(BaseModel): provider = OAuthProvider( package=__package__, config_model=Config, - icon=(pathlib.Path(__file__).parent / "icon.svg").read_text() + icon=(pathlib.Path(__file__).parent / "icon.svg").read_text(), ) diff --git a/back/app/routes/youtube/youtube.py b/back/app/routes/youtube/youtube.py index 3ec23b1..a0d6256 100644 --- a/back/app/routes/youtube/youtube.py +++ b/back/app/routes/youtube/youtube.py @@ -40,9 +40,10 @@ class Config(BaseModel): provider = OAuthProvider( package=__package__, config_model=Config, - icon=(pathlib.Path(__file__).parent / "icon.svg").read_text() + icon=(pathlib.Path(__file__).parent / "icon.svg").read_text(), ) + @router.get("/connect") async def youtube_connect(token: str = Query(...), platform: str = Query(...)): return await provider.connect(token, platform) diff --git a/back/app/service.py b/back/app/service.py index dc93680..a3e64d2 100644 --- a/back/app/service.py +++ b/back/app/service.py @@ -10,24 +10,32 @@ def __init__(self, name: str, description: str): def action(self, name: str, description: str): """Decorator to register an action with metadata.""" + def wrapper(func: Callable): - self.actions.append({ - "name": name, - "description": description, - "function": func, - }) + self.actions.append( + { + "name": name, + "description": description, + "function": func, + } + ) return func + return wrapper def reaction(self, name: str, description: str): """Decorator to register a reaction with metadata.""" + def wrapper(func: Callable): - self.reactions.append({ - "name": name, - "description": description, - "function": func, - }) + self.reactions.append( + { + "name": name, + "description": description, + "function": func, + } + ) return func + return wrapper def to_dict(self) -> dict[str, Any]: diff --git a/front/src/routes/not-found/index.tsx b/front/src/routes/not-found/index.tsx new file mode 100644 index 0000000..5ac0a9d --- /dev/null +++ b/front/src/routes/not-found/index.tsx @@ -0,0 +1,17 @@ +import { Link } from "react-router"; +import "./style.scss"; + +export default function NotFoundPage() { + return ( +
+
+

404

+

Page Not Found

+

Sorry, the page you are looking for does not exist.

+ + Go Home + +
+
+ ); +} diff --git a/front/src/routes/not-found/style.scss b/front/src/routes/not-found/style.scss new file mode 100644 index 0000000..6c24359 --- /dev/null +++ b/front/src/routes/not-found/style.scss @@ -0,0 +1,30 @@ +.notfound-page { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100vh; + text-align: center; + padding: 1em; + box-sizing: border-box; + + .notfound-content { + h1 { + font-size: 6rem; + margin: 0; + color: #e4bef8; + } + + h2 { + font-size: 2rem; + margin: 10px 0; + color: #c6d0f5; + } + + p { + margin: 15px 0 25px; + color: #c6d0f5; + font-size: 1rem; + } + } +} diff --git a/front/src/routes/workflow-create/index.tsx b/front/src/routes/workflow-create/index.tsx index 3f46a9d..75609ab 100644 --- a/front/src/routes/workflow-create/index.tsx +++ b/front/src/routes/workflow-create/index.tsx @@ -17,6 +17,8 @@ export default function WorkflowList() { const [workflows, setWorkflows] = useState([]); const [newWorkflow, setNewWorkflow] = useState({ name: "", description: "" }); const [loading, setLoading] = useState(true); + const [editingId, setEditingId] = useState(null); + const [editingData, setEditingData] = useState({ name: "", description: "" }); const createNewWorkflow = async (e: React.FormEvent) => { e.preventDefault(); @@ -60,13 +62,46 @@ export default function WorkflowList() { } }; + const startEditing = (workflow: Workflow) => { + setEditingId(workflow.id); + setEditingData({ + name: workflow.name, + description: workflow.description || "", + }); + }; + + const cancelEditing = () => { + setEditingId(null); + setEditingData({ name: "", description: "" }); + }; + + const saveEditing = async (id: number) => { + try { + const response = await fetch(`${API_BASE_URL}/workflow/${id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(editingData), + }); + if (!response.ok) throw new Error("Failed to update workflow"); + + const updated = await response.json(); + setWorkflows((prev) => prev.map((w) => (w.id === id ? updated : w))); + cancelEditing(); + } catch (error) { + console.error(error); + alert("Failed to update workflow"); + } + }; + useEffect(() => { fetch(`${API_BASE_URL}/workflow`, { headers: { Authorization: `Bearer ${token}` }, }) .then((response) => response.json()) .then((data) => { - console.log(data); setWorkflows(data); setLoading(false); }) @@ -107,20 +142,75 @@ export default function WorkflowList() { {workflows.map((workflow) => (
  • -

    {workflow.name}

    -

    {workflow.description || "No description"}

    + {editingId === workflow.id ? ( + <> + + setEditingData((d) => ({ ...d, name: e.target.value })) + } + /> + + setEditingData((d) => ({ + ...d, + description: e.target.value, + })) + } + /> + + ) : ( + <> +

    {workflow.name}

    +

    {workflow.description || "No description"}

    + + )}
    - - View - - + {editingId === workflow.id ? ( + <> + + + + ) : ( + <> + + View + + + + + )}
  • ))} diff --git a/front/src/routes/workflow-create/style.scss b/front/src/routes/workflow-create/style.scss index 4867362..d661da0 100644 --- a/front/src/routes/workflow-create/style.scss +++ b/front/src/routes/workflow-create/style.scss @@ -41,7 +41,22 @@ outline: none; } } +} + +.workflow-card { + input { + background-color: #313244; + flex: 1; + padding: 0.6rem 0.8rem; + border: 1px solid #585b70; + font-size: 1rem; + transition: border-color 0.2s; + &:focus { + border-color: #96bfff; + outline: none; + } + } } .workflow-items { diff --git a/front/src/routes/workflow/index.tsx b/front/src/routes/workflow/index.tsx index 42df131..6cf686a 100644 --- a/front/src/routes/workflow/index.tsx +++ b/front/src/routes/workflow/index.tsx @@ -1,5 +1,4 @@ import { - addEdge, Background, type Connection, Controls, @@ -29,9 +28,11 @@ export default function GraphPage() { const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [loading, setLoading] = useState(true); + const [selectedNode, setSelectedNode] = useState(null); + const [selectedEdge, setSelectedEdge] = useState(null); + const onConnect = useCallback( async (params: Connection) => { - setEdges((els) => addEdge(params, els)); if (!workflowId || !token) return; try { @@ -59,7 +60,7 @@ export default function GraphPage() { setEdges((eds) => [ ...eds, { - id: `e${savedEdge.from_node_id}-${savedEdge.to_node_id}`, + id: savedEdge.id, source: savedEdge.from_node_id.toString(), target: savedEdge.to_node_id.toString(), animated: true, @@ -72,6 +73,63 @@ export default function GraphPage() { [workflowId, token, setEdges] ); + const onEdgeClick = useCallback((event, edge) => { + event.stopPropagation(); + setSelectedEdge(edge); + setSelectedNode(null); + }, []); + + const onNodeClick = useCallback((event, node) => { + event.stopPropagation(); + setSelectedNode(node); + setSelectedEdge(null); + }, []); + + const handleDeleteSelected = useCallback(async () => { + if (!token) return; + + try { + if (selectedNode) { + const nodeId = selectedNode.id; + const res = await fetch(`${API_BASE_URL}/workflow/nodes/${nodeId}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) { + console.error("Failed to delete node:", res.statusText); + return; + } + + setNodes((nds) => nds.filter((n) => n.id !== nodeId)); + setSelectedNode(null); + } + + if (selectedEdge) { + const edgeId = selectedEdge.id; + const res = await fetch(`${API_BASE_URL}/workflow/edges/${edgeId}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) { + console.error("Failed to delete edge:", res.statusText); + return; + } + + setEdges((eds) => eds.filter((e) => e.id !== edgeId)); + setSelectedEdge(null); + } + } catch (err) { + console.error("Error deleting item:", err); + } + }, [selectedNode, selectedEdge, token, setNodes, setEdges]); + + const onPaneClick = useCallback(() => { + setSelectedNode(null); + setSelectedEdge(null); + }, []); + useEffect(() => { const fetchWorkflow = async () => { if (!workflowId || !token) return; @@ -108,10 +166,11 @@ export default function GraphPage() { headers: { Authorization: `Bearer ${token}` }, } ); + const dataEdges = await resEdges.json(); const fetchedEdges = dataEdges.map((edge) => ({ - id: `e${edge.from_node_id}-${edge.to_node_id}`, + id: edge.id, source: edge.from_node_id.toString(), target: edge.to_node_id.toString(), animated: true, @@ -157,7 +216,7 @@ export default function GraphPage() { ...nds, { ...newNode, - id: savedNode.id.toString(), + id: savedNode.id, data: { label: `Node ${savedNode.id}` }, }, ]); @@ -169,7 +228,8 @@ export default function GraphPage() { if (loading) return
    Loading workflow...
    ; return ( -
    + /* biome-ignore lint: this is not going to be possible */ +
    + + {(selectedNode || selectedEdge) && ( + + )} +