From 7e18fd6d2b0d21ed376110b2d85feefdb25b062e Mon Sep 17 00:00:00 2001 From: Sigmanificient Date: Wed, 5 Nov 2025 02:32:43 +0100 Subject: [PATCH 1/7] fix(front): add node deletion --- front/src/routes/workflow/index.tsx | 80 +++++++++++++++++++++++++--- front/src/routes/workflow/style.scss | 14 +++++ 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/front/src/routes/workflow/index.tsx b/front/src/routes/workflow/index.tsx index 42df131..30e68ca 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, @@ -28,10 +27,14 @@ export default function GraphPage() { const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [loading, setLoading] = useState(true); + const [selectedEdge, setSelectedEdge] = useState(null); + const [deleteButtonPos, setDeleteButtonPos] = useState<{ + x: number; + y: number; + } | null>(null); const onConnect = useCallback( async (params: Connection) => { - setEdges((els) => addEdge(params, els)); if (!workflowId || !token) return; try { @@ -59,7 +62,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 +75,46 @@ export default function GraphPage() { [workflowId, token, setEdges] ); + const onEdgeClick = useCallback((event, edge) => { + event.stopPropagation(); + setSelectedEdge(edge); + + const edgePath = event.target.getBoundingClientRect(); + setDeleteButtonPos({ + x: edgePath.left + edgePath.width / 2, + y: edgePath.top + edgePath.height / 2, + }); + }, []); + + const handleDeleteEdge = useCallback(async () => { + if (!selectedEdge || !token) return; + + try { + 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 !== selectedEdge.id)); + setSelectedEdge(null); + setDeleteButtonPos(null); + } catch (err) { + console.error("Error deleting edge:", err); + } + }, [selectedEdge, token, setEdges]); + + const onPaneClick = useCallback(() => { + setSelectedEdge(null); + setDeleteButtonPos(null); + }, []); + useEffect(() => { const fetchWorkflow = async () => { if (!workflowId || !token) return; @@ -108,12 +151,13 @@ 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}`, - source: edge.from_node_id.toString(), - target: edge.to_node_id.toString(), + id: edge.id, + source: edge.from_node_id, + target: edge.to_node_id, animated: true, })); setEdges(fetchedEdges); @@ -157,7 +201,7 @@ export default function GraphPage() { ...nds, { ...newNode, - id: savedNode.id.toString(), + id: savedNode.id, data: { label: `Node ${savedNode.id}` }, }, ]); @@ -169,7 +213,7 @@ export default function GraphPage() { if (loading) return
Loading workflow...
; return ( -
+
+ + {deleteButtonPos && ( + + )} + )} diff --git a/front/src/routes/workflow/style.scss b/front/src/routes/workflow/style.scss index 7539794..730cfd8 100644 --- a/front/src/routes/workflow/style.scss +++ b/front/src/routes/workflow/style.scss @@ -19,16 +19,29 @@ position: relative; } -.delete-edge-btn { - color: white; +.delete-floating-btn { + position: fixed; + bottom: 20px; + right: 20px; + color: #fff; border: none; - border-radius: 50%; - padding: 1em; - font-size: 16px; + border-radius: 9999px; + padding: 0.75rem 1.25rem; + font-size: 1rem; + font-weight: 600; + box-shadow: 0 4px 10px rgba(0,0,0,0.25); + z-index: 100; display: flex; align-items: center; - justify-content: center; - box-shadow: 0 2px 6px rgba(0,0,0,0.2); + gap: 6px; cursor: pointer; - z-index: 10; + transition: background-color 0.2s, transform 0.1s ease-in-out; + + &:hover { + background-color: #dc2626; + } + + &:active { + transform: scale(0.95); + } } From a5e3ae27a228c2053859f634889e2e7180a77614 Mon Sep 17 00:00:00 2001 From: Sigmanificient Date: Wed, 5 Nov 2025 03:01:13 +0100 Subject: [PATCH 3/7] fix(back): repair oauth token save --- back/app/db/models/oauth.py | 2 +- back/app/routes/oauth_base.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/routes/oauth_base.py b/back/app/routes/oauth_base.py index 32b5639..9c79ee9 100644 --- a/back/app/routes/oauth_base.py +++ b/back/app/routes/oauth_base.py @@ -172,7 +172,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"), From c4e81d5a8bd7a3a06bb5921b15835b5f053614d8 Mon Sep 17 00:00:00 2001 From: Sigmanificient Date: Wed, 5 Nov 2025 03:49:37 +0100 Subject: [PATCH 4/7] feat(front): Add edit button --- front/src/routes/workflow-create/index.tsx | 116 +++++++++++++++++--- front/src/routes/workflow-create/style.scss | 15 +++ 2 files changed, 118 insertions(+), 13 deletions(-) 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 { From caed6f4aac7845eb8f01f5c43fd4b479d61a6a01 Mon Sep 17 00:00:00 2001 From: Sigmanificient Date: Wed, 5 Nov 2025 03:56:41 +0100 Subject: [PATCH 5/7] feat(front): add 404 page --- front/src/routes/not-found/index.tsx | 17 +++++++++++++++ front/src/routes/not-found/style.scss | 30 +++++++++++++++++++++++++++ front/src/web/entrypoint.tsx | 2 ++ 3 files changed, 49 insertions(+) create mode 100644 front/src/routes/not-found/index.tsx create mode 100644 front/src/routes/not-found/style.scss 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/web/entrypoint.tsx b/front/src/web/entrypoint.tsx index 5dc6bb8..41a9a48 100644 --- a/front/src/web/entrypoint.tsx +++ b/front/src/web/entrypoint.tsx @@ -4,6 +4,7 @@ import MainLayout from "@/layouts/main"; import ConnectServicesPage from "@/routes/connect-services"; import HomePage from "@/routes/home"; import LoginPage from "@/routes/login"; +import NotFoundPage from "@/routes/not-found"; import ProfilePage from "@/routes/profile"; import RegisterPage from "@/routes/register"; import GraphPage from "@/routes/workflow"; @@ -23,6 +24,7 @@ function WebApp() { } /> } /> } /> + } /> From 4ea3704c027d88a8fdbb051a5227e42182604500 Mon Sep 17 00:00:00 2001 From: Sigmanificient Date: Wed, 5 Nov 2025 09:28:51 +0100 Subject: [PATCH 6/7] feat(back): add about.json Co-authored-by: Ciznia --- back/app/routes/__init__.py | 1 + back/app/routes/about.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 back/app/routes/about.py diff --git a/back/app/routes/__init__.py b/back/app/routes/__init__.py index dbfe8e8..e1f11f4 100644 --- a/back/app/routes/__init__.py +++ b/back/app/routes/__init__.py @@ -4,6 +4,7 @@ routers = [] providers = ( + "about", "auth", "hello", "workflow", diff --git a/back/app/routes/about.py b/back/app/routes/about.py new file mode 100644 index 0000000..d5b4501 --- /dev/null +++ b/back/app/routes/about.py @@ -0,0 +1,33 @@ +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +from .oauth_base import OAuthProvider +import time + +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 + ] + }, + } + ) From 4e6fbd814be89aad25d26c73efca64caeeb5fcb5 Mon Sep 17 00:00:00 2001 From: Sigmanificient Date: Wed, 5 Nov 2025 09:39:38 +0100 Subject: [PATCH 7/7] refactor(back): apply formatting --- back/app/db/models/user.py | 2 +- back/app/routes/__init__.py | 10 ++++++---- back/app/routes/about.py | 14 +++++++------- back/app/routes/auth.py | 3 ++- back/app/routes/caldav/google.py | 2 +- back/app/routes/discord/discord.py | 2 +- back/app/routes/gmail/gmail.py | 2 +- back/app/routes/oauth_base.py | 8 ++------ back/app/routes/spotify/spotify.py | 2 +- back/app/routes/youtube/youtube.py | 3 ++- back/app/service.py | 28 ++++++++++++++++++---------- 11 files changed, 42 insertions(+), 34 deletions(-) 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 e1f11f4..b54e68d 100644 --- a/back/app/routes/__init__.py +++ b/back/app/routes/__init__.py @@ -20,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 index d5b4501..f0cb029 100644 --- a/back/app/routes/about.py +++ b/back/app/routes/about.py @@ -1,8 +1,9 @@ +import time + from fastapi import APIRouter, Request from fastapi.responses import JSONResponse from .oauth_base import OAuthProvider -import time router = APIRouter(prefix="") @@ -22,12 +23,11 @@ async def about_json(request: Request): "services": [ { "name": service_name, - "actions": [ - ], - "reactions": [ - ], - } for service_name in services - ] + "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 9c79ee9..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(".") @@ -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]: