diff --git a/README.md b/README.md index 9a18744..305bc15 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Matrix Directory is a community-driven web application for discovering projects in the Matrix ecosystem, including bots, frameworks, SDKs, and other tools. -It provides a Vue frontend for discovering and managing listings and a FastAPI +It provides a Vue frontend for discovering and managing projects and a FastAPI backend for authentication, profiles, project ownership, and directory data. ## Tech stack diff --git a/backend/app/main.py b/backend/app/main.py index 5035350..dc6692f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware @@ -58,6 +58,11 @@ def mount_static_sites(application: FastAPI, static_directory: Path) -> None: frontend_assets = static_directory / "assets" if documentation_directory.is_dir(): + + @application.get("/docs", include_in_schema=False) + def documentation_root_redirect() -> RedirectResponse: + return RedirectResponse(url="/docs/", status_code=307) + application.mount( "/docs", StaticFiles(directory=documentation_directory, html=True), diff --git a/backend/app/routers/project_routers.py b/backend/app/routers/project_routers.py index bd066d2..b0cd1d2 100644 --- a/backend/app/routers/project_routers.py +++ b/backend/app/routers/project_routers.py @@ -1,6 +1,6 @@ from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlmodel import Session from app.database import get_session @@ -23,9 +23,21 @@ @router.get("/", response_model=list[ProjectRead]) def list_projects( + q: str | None = Query(default=None, max_length=200), + project_type: str | None = Query(default=None, max_length=100), + label: str | None = Query(default=None, max_length=100), + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), session: Session = Depends(get_session), ) -> list[Project]: - return projects_service.list_projects(session) + return projects_service.list_projects( + session, + query=q, + project_type=project_type, + label=label, + limit=limit, + offset=offset, + ) @router.get("/mine/", response_model=list[ProjectRead]) @@ -36,6 +48,24 @@ def list_my_projects( return projects_service.list_projects_for_user(session, user_id=user.id) +@router.get("/count/", response_model=int) +def count_projects( + session: Session = Depends(get_session), +) -> int: + return projects_service.count_projects(session) + + +@router.get("/random/", response_model=list[ProjectRead]) +def list_random_projects( + limit: int = Query(default=6, ge=1, le=24), + session: Session = Depends(get_session), +) -> list[Project]: + return projects_service.list_random_projects( + session, + limit=limit, + ) + + @router.get("/{project_id}", response_model=ProjectRead) def get_project( project_id: UUID, diff --git a/backend/app/services/projects_service.py b/backend/app/services/projects_service.py index 2935cf4..8f2c7e8 100644 --- a/backend/app/services/projects_service.py +++ b/backend/app/services/projects_service.py @@ -1,21 +1,102 @@ from uuid import UUID -from sqlmodel import Session, select +from sqlalchemy import func, or_ +from sqlmodel import Session, col, select +from app.models.label import Label +from app.models.profile import Profile from app.models.project import Project +from app.models.project_label import ProjectLabel +from app.models.project_type import ProjectType from app.schemas.project import ProjectCreate, ProjectUpdate from . import labels_service, project_types_service from .errors import ProjectLinkRequiredError -def list_projects(session: Session) -> list[Project]: +def list_projects( + session: Session, + *, + query: str | None = None, + project_type: str | None = None, + label: str | None = None, + limit: int = 50, + offset: int = 0, +) -> list[Project]: statement = select(Project) + + if query: + pattern = f"%{query.strip()}%" + matching_project_types = select(ProjectType.id).where( + col(ProjectType.name).ilike(pattern) + ) + matching_project_labels = ( + select(ProjectLabel.project_id) + .join(Label, col(ProjectLabel.label_id) == col(Label.id)) + .where(col(Label.name).ilike(pattern)) + ) + matching_owners = select(Profile.user_id).where( + or_( + col(Profile.display_name).ilike(pattern), + col(Profile.matrix_id).ilike(pattern), + ) + ) + statement = statement.where( + or_( + col(Project.name).ilike(pattern), + col(Project.short_description).ilike(pattern), + col(Project.description).ilike(pattern), + col(Project.project_type_id).in_(matching_project_types), + col(Project.id).in_(matching_project_labels), + col(Project.user_id).in_(matching_owners), + ) + ) + + if project_type: + matching_project_type = select(ProjectType.id).where( + ProjectType.name == project_type + ) + statement = statement.where( + col(Project.project_type_id).in_(matching_project_type) + ) + + if label: + matching_label = ( + select(ProjectLabel.project_id) + .join(Label, col(ProjectLabel.label_id) == col(Label.id)) + .where(col(Label.name) == label) + ) + statement = statement.where(col(Project.id).in_(matching_label)) + + statement = ( + statement.order_by(col(Project.created_at).desc()).offset(offset).limit(limit) + ) + return list(session.exec(statement).all()) + + +def count_projects(session: Session) -> int: + statement = select(func.count()).select_from(Project) + return int(session.exec(statement).one()) + + +def list_random_projects( + session: Session, + *, + limit: int = 6, +) -> list[Project]: + statement = select(Project).order_by(func.random()).limit(limit) return list(session.exec(statement).all()) def list_projects_for_user(session: Session, *, user_id: UUID) -> list[Project]: - statement = select(Project).where(Project.user_id == user_id) + statement = ( + select(Project) + .where(Project.user_id == user_id) + .order_by( + col(Project.updated_at).desc(), + col(Project.created_at).desc(), + ) + ) return list(session.exec(statement).all()) diff --git a/backend/tests/test_project_validation.py b/backend/tests/test_project_validation.py index e7d8d3e..154abcf 100644 --- a/backend/tests/test_project_validation.py +++ b/backend/tests/test_project_validation.py @@ -108,6 +108,76 @@ def test_create__expect_project_associated_with_authenticated_user( assert [label["id"] for label in body["labels"]] == [str(project_client.label_id)] +def test_random_projects__expect_requested_limit_and_total_count( + project_client: ProjectClient, +) -> None: + for index in range(3): + response = project_client.client.post( + "/api/projects/", + json={ + "name": f"Test Project {index}", + "description": "A useful project.", + "short_description": "Useful project", + "repository_url": f"https://example.com/project-{index}", + "project_type_id": str(project_client.project_type_id), + "label_ids": [], + }, + ) + assert response.status_code == 201 + + random_response = project_client.client.get("/api/projects/random/?limit=2") + count_response = project_client.client.get("/api/projects/count/") + + assert random_response.status_code == 200 + assert len(random_response.json()) == 2 + assert count_response.status_code == 200 + assert count_response.json() == 3 + + +@pytest.mark.parametrize("limit", [0, 25]) +def test_random_projects_with_invalid_limit__expect_validation_error( + project_client: ProjectClient, + limit: int, +) -> None: + response = project_client.client.get(f"/api/projects/random/?limit={limit}") + + assert response.status_code == 422 + + +def test_list_projects__expect_server_side_search_and_filters( + project_client: ProjectClient, +) -> None: + for name, labels in [ + ("Alpha Bridge", [str(project_client.label_id)]), + ("Beta Bot", []), + ]: + response = project_client.client.post( + "/api/projects/", + json={ + "name": name, + "description": f"Description for {name}", + "short_description": name, + "repository_url": f"https://example.com/{name.lower().replace(' ', '-')}", + "project_type_id": str(project_client.project_type_id), + "label_ids": labels, + }, + ) + assert response.status_code == 201 + + response = project_client.client.get( + "/api/projects/", + params={ + "q": "alpha", + "project_type": "Bot", + "label": "Utility", + "limit": 24, + }, + ) + + assert response.status_code == 200 + assert [project["name"] for project in response.json()] == ["Alpha Bridge"] + + @pytest.mark.parametrize( ("project_type_id", "label_ids", "message"), [ diff --git a/backend/tests/test_static_sites.py b/backend/tests/test_static_sites.py index 4e87c6e..18d8691 100644 --- a/backend/tests/test_static_sites.py +++ b/backend/tests/test_static_sites.py @@ -34,14 +34,21 @@ def test_documentation_static_files__expect_served_under_docs( encoding="utf-8", ) - application = FastAPI() + application = FastAPI( + docs_url=None, + redoc_url=None, + openapi_url=None, + ) mount_static_sites(application, static_directory) client = TestClient(application) home_response = client.get("/docs/") + home_redirect_response = client.get("/docs", follow_redirects=False) guide_response = client.get("/docs/guide/") assert home_response.status_code == 200 + assert home_redirect_response.status_code == 307 + assert home_redirect_response.headers["location"] == "/docs/" assert "Documentation home" in home_response.text assert guide_response.status_code == 200 assert "Documentation guide" in guide_response.text @@ -57,7 +64,11 @@ def test_reserved_routes__expect_not_fall_through_to_frontend( encoding="utf-8", ) - application = FastAPI() + application = FastAPI( + docs_url=None, + redoc_url=None, + openapi_url=None, + ) mount_static_sites(application, static_directory) client = TestClient(application) diff --git a/docs/docs/guides/submitting-a-project.md b/docs/docs/guides/submitting-a-project.md index 4b24b36..1f8faf1 100644 --- a/docs/docs/guides/submitting-a-project.md +++ b/docs/docs/guides/submitting-a-project.md @@ -16,31 +16,31 @@ Prepare the following information: You may also provide a Matrix room and indicate whether the project supports end-to-end encrypted rooms. -!!! note "Listings are public" +!!! note "Projects are public" Do not include secrets, access tokens, private room links, or other information that should not appear in the public directory. -## Publish a listing +## Publish a project 1. Sign in to Matrix Directory. 2. Open your dashboard. -3. Select **Add listing**. -4. Complete the required fields. The listing status panel shows which required +3. Select **Add project**. +4. Complete the required fields. The project status panel shows which required information is still missing. 5. Review the directory-card preview. -6. Select **Publish listing**. +6. Select **Publish project**. -The listing is associated with your authenticated account and published +A project is associated with your authenticated account and published immediately. After a successful submission, the application redirects you to -the new public listing. +the new public view of the project. ## Types and labels -A project type describes what the project **is**. Every listing has exactly +A project type describes what the project **is**. Every project has exactly one of the initial directory types: **Bot**, **SDK**, **Framework**, **Bridges**, **Clients**, **Server**, or **Integrations**. -Labels describe what the project **does**. A listing may have any number of +Labels describe what the project **does**. A project may have any number of labels, including none. Examples include **Dev tools** and **Utility**. This separation lets visitors filter by both the project format and its @@ -63,10 +63,10 @@ purpose. URLs must be absolute `http://` or `https://` URLs no longer than 255 characters. -At least one repository or website must remain on the listing when it is +At least one repository or website must remain on the project when it is updated later. -!!! note "Listings are public" +!!! note "Projects are public" Do not include secrets, access tokens, private room links, or other information that should not appear in the public directory. @@ -84,25 +84,25 @@ The **About** editor supports CommonMark formatting, including: Raw HTML is displayed as text rather than interpreted as page markup. -Images embedded with Markdown are not rendered on saved listings. When the +Images embedded with Markdown are not rendered on saved projects. When the Markdown is parsed, image nodes are reduced to their alternative text to -prevent a listing from making visitors contact an untrusted image host. Use a +prevent a project from making visitors contact an untrusted image host. Use a normal link when readers need access to a screenshot or diagram. -## Manage your listings +## Manage your projects Open the dashboard to see projects owned by your account. Ownership comes from your authenticated session; it cannot be assigned to another user through the submission form. -Only an owner can update or delete their listing. Deleting a listing is +Only an owner can update or delete their project. Deleting a project is permanent, so confirm that you selected the intended project before proceeding. ## Troubleshooting -If a listing cannot be published: +If a project cannot be published: -- Check the listing status panel for missing required fields. +- Check the project status panel for missing required fields. - Confirm that every URL is absolute and begins with `http://` or `https://`. - Confirm that at least one repository or website is present. - Confirm that at least one category is selected. diff --git a/docs/docs/img/favicon.svg b/docs/docs/img/favicon.svg index 089e2ee..078f018 100644 --- a/docs/docs/img/favicon.svg +++ b/docs/docs/img/favicon.svg @@ -1 +1,45 @@ - \ No newline at end of file + + + + + + + + + + + \ No newline at end of file diff --git a/docs/docs/img/matrix-directory-mark-light.svg b/docs/docs/img/matrix-directory-mark-light.svg new file mode 100644 index 0000000..a86157c --- /dev/null +++ b/docs/docs/img/matrix-directory-mark-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/docs/img/matrix-directory-mark.svg b/docs/docs/img/matrix-directory-mark.svg index a86157c..06a261a 100644 --- a/docs/docs/img/matrix-directory-mark.svg +++ b/docs/docs/img/matrix-directory-mark.svg @@ -1 +1,50 @@ - \ No newline at end of file + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 1584eba..e04afd8 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -50,7 +50,7 @@ theme: - toc.follow icon: repo: fontawesome/brands/github-alt - logo: img/matrix-directory-mark.svg + logo: img/matrix-directory-mark-light.svg favicon: img/favicon.svg plugins: - search diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 089e2ee..078f018 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1 +1,45 @@ - \ No newline at end of file + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 24c1dc4..fe17502 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,11 +1,15 @@ diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index aa7bcd6..a45f734 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -76,8 +76,27 @@ async function request(path: string, init?: RequestInit): Promise { return response.json() as Promise } -export function listProjects() { - return request('/projects/') +type ProjectQuery = { + query?: string + projectType?: string + label?: string + limit?: number + offset?: number +} + +export function listProjects(filters: ProjectQuery = {}) { + const params = new URLSearchParams() + + if (filters.query) params.set('q', filters.query) + if (filters.projectType) params.set('project_type', filters.projectType) + if (filters.label) params.set('label', filters.label) + if (filters.limit !== undefined) params.set('limit', String(filters.limit)) + if (filters.offset !== undefined) params.set('offset', String(filters.offset)) + + const queryString = params.toString() + const path = queryString ? `/projects/?${queryString}` : '/projects/' + + return request(path) } export function getProject(id: string) { @@ -111,6 +130,20 @@ export function deleteProject(id: string) { }) } +export function listRandomProjects(limit = 6) { + const params = new URLSearchParams({ + limit: String(limit), + }) + + return request( + `/projects/random/?${params.toString()}`, + ) +} + +export function countProjects() { + return request('/projects/count/') +} + export function listProjectTypes() { return request('/project-types/') } @@ -125,3 +158,10 @@ export function createProject(input: ProjectCreate) { body: JSON.stringify(input), }) } + +export function updateProject(id: string, input: ProjectCreate) { + return request(`/projects/${id}`, { + method: 'PATCH', + body: JSON.stringify(input), + }) +} diff --git a/frontend/src/components/BrandLogo.vue b/frontend/src/components/BrandLogo.vue new file mode 100644 index 0000000..b9e4b5a --- /dev/null +++ b/frontend/src/components/BrandLogo.vue @@ -0,0 +1,28 @@ + \ No newline at end of file diff --git a/frontend/src/components/MatrixLogo.vue b/frontend/src/components/MatrixLogo.vue new file mode 100644 index 0000000..52265c6 --- /dev/null +++ b/frontend/src/components/MatrixLogo.vue @@ -0,0 +1,10 @@ + diff --git a/frontend/src/components/ProjectActions.vue b/frontend/src/components/ProjectActions.vue new file mode 100644 index 0000000..a10f8a7 --- /dev/null +++ b/frontend/src/components/ProjectActions.vue @@ -0,0 +1,164 @@ + + + diff --git a/frontend/src/components/ProjectBrowseRow.vue b/frontend/src/components/ProjectBrowseRow.vue new file mode 100644 index 0000000..fe2b2df --- /dev/null +++ b/frontend/src/components/ProjectBrowseRow.vue @@ -0,0 +1,163 @@ + + + diff --git a/frontend/src/components/BotCard.vue b/frontend/src/components/ProjectCard.vue similarity index 98% rename from frontend/src/components/BotCard.vue rename to frontend/src/components/ProjectCard.vue index df91e03..17a5ace 100644 --- a/frontend/src/components/BotCard.vue +++ b/frontend/src/components/ProjectCard.vue @@ -4,6 +4,7 @@ import { RouterLink } from 'vue-router' import { CheckIcon } from '@heroicons/vue/24/outline' import type { ProjectListItem } from '../types/project' +import { projectPath } from '../utils/projectRoutes' const props = defineProps<{ project: ProjectListItem @@ -36,7 +37,7 @@ const ownerInitial = computed(() =>