diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ec923bd..2c9e9f5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "fr", - "version": "0.1.0", + "version": "4.10.0-dev.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fr", - "version": "0.1.0", + "version": "4.10.0-dev.21", "dependencies": { "@emotion/react": "^11.13.5", "@emotion/styled": "^11.13.5", @@ -28,6 +28,7 @@ "react-scripts": "5.0.1", "styled-components": "^6.1.13", "typescript": "^4.4.2", + "use-debounce": "^10.0.4", "web-vitals": "^2.1.0" } }, @@ -17212,6 +17213,18 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-debounce": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.4.tgz", + "integrity": "sha512-6Cf7Yr7Wk7Kdv77nnJMf6de4HuDE4dTxKij+RqE9rufDsI6zsbjyAxcH5y2ueJCQAnfgKbzXbZHYlkFwmBlWkw==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6dd4a70..cc8ff7a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "react-scripts": "5.0.1", "styled-components": "^6.1.13", "typescript": "^4.4.2", + "use-debounce": "^10.0.4", "web-vitals": "^2.1.0" }, "scripts": { diff --git a/frontend/src/page/admin/GuildDetails.tsx b/frontend/src/page/admin/GuildDetails.tsx index 3d1fb08..d5c89e7 100644 --- a/frontend/src/page/admin/GuildDetails.tsx +++ b/frontend/src/page/admin/GuildDetails.tsx @@ -1,4 +1,4 @@ -import {fetchGuildDetails, GuildDetails as GuildDetailsDto} from "../../service/api"; +import {fetchGuildDetails, fetchGuildTags, GuildDetails as GuildDetailsDto} from "../../service/api"; import {Base} from "../../component/Base"; import { Accordion, @@ -11,12 +11,13 @@ import { Typography } from "@mui/material"; import {useParams} from "react-router-dom"; -import React, {Suspense, use} from "react"; +import React, {Suspense, use, useEffect, useState} from "react"; import {getGuildNameElement} from "../../guildUtil"; import {DataGrid, GridColDef} from "@mui/x-data-grid"; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import {parseISO} from "date-fns"; import {format} from "date-fns/format"; +import {useDebounce} from "use-debounce"; interface GuildDetailsDataProps { dataPromise: Promise @@ -33,13 +34,13 @@ function FeaturesPaper({dataPromise}: GuildDetailsDataProps) { {f.name} (enabled: {enabledAt}) - Enabled by: {f.enabledBy} - Data: {JSON.stringify(f.data)} + Enabled by: {f.enabledBy} + Data: {JSON.stringify(f.data)} ; }); - return + return Features
{enabledFeatures} @@ -50,6 +51,18 @@ function FeaturesPaper({dataPromise}: GuildDetailsDataProps) { function TagsDataPaper({dataPromise}: GuildDetailsDataProps) { const data = use(dataPromise); + const [tags, setTags] = useState(data.tags); + const [searchQuery, setSearchQuery] = useState(null); + const [searchQueryDebounced] = useDebounce(searchQuery, 500); + + useEffect(() => { + if (searchQueryDebounced == null) { + return; + } + + fetchGuildTags({id: data.id, query: searchQueryDebounced}).then((t) => setTags(t)); + }, [searchQueryDebounced]); + const columns: GridColDef[] = [ { field: 'name', headerName: 'Name' }, { field: 'content', headerName: 'Content', flex: 1 }, @@ -60,9 +73,9 @@ function TagsDataPaper({dataPromise}: GuildDetailsDataProps) { return Tags - + setSearchQuery(e.target.value)} /> - + ; } diff --git a/frontend/src/service/api.ts b/frontend/src/service/api.ts index b78e2de..4eea930 100644 --- a/frontend/src/service/api.ts +++ b/frontend/src/service/api.ts @@ -135,3 +135,18 @@ export async function fetchGuilds(): Promise { export async function fetchGuildDetails(id: string): Promise { return await request({path: `/api/guilds/${id}`, auth: true}); } + +interface FetchGuildTags { + id: string, + perPage?: number, + query?: string, +} + +export async function fetchGuildTags({id, perPage = 5, query}: FetchGuildTags): Promise { + const params = [["perPage", perPage.toString()]]; + if (query != null && query !== '') { + params.push(["query", query]) + } + + return await request({path: `/api/guilds/${id}/tags`, auth: true, searchParams: params}); +} diff --git a/frontend/src/service/httpClient.ts b/frontend/src/service/httpClient.ts index ee5caf5..80314a3 100644 --- a/frontend/src/service/httpClient.ts +++ b/frontend/src/service/httpClient.ts @@ -6,13 +6,14 @@ export interface FetchParams { path: string, method?: string, auth?: boolean + searchParams?: string|string[][]|URLSearchParams|Record } interface ErrorResponse { message: string } -export async function request({path, method = 'GET', auth = false}: FetchParams) { +export async function request({path, method = 'GET', auth = false, searchParams}: FetchParams) { const headers = new Headers(); headers.append('Accept', 'application/json'); @@ -20,8 +21,13 @@ export async function request({path, method = 'GET', auth = fa headers.append('authorization', `Bearer ${getToken()}`) } + const url = new URL(`${API_SERVER}${path}`); + if (searchParams != undefined) { + url.search = new URLSearchParams(searchParams).toString(); + } + const response = await fetch( - `${API_SERVER}${path}`, + url, { method: method, headers: headers, @@ -33,7 +39,7 @@ export async function request({path, method = 'GET', auth = fa return responseBody as T; } - if ([400, 401].includes(response.status)) { + if ([401, 403].includes(response.status)) { logout(); } diff --git a/lib/src/settings.dart b/lib/src/settings.dart index dbe8bfa..d24b1ad 100644 --- a/lib/src/settings.dart +++ b/lib/src/settings.dart @@ -88,6 +88,9 @@ final int webServerPort = getEnvInt('WEB_SERVER_PORT', 8088); /// Path to templates directory final String webServerTemplatesDirectory = getEnv('WEB_SERVER_TEMPLATES_DIRECTORY', "./templates"); +/// Allowed origins for cors headers +final String webServerAllowedOrigins = getEnv('WEB_SERVER_ALLOWED_ORIGIN', 'rod.l7ssha.xyz'); + /// The GitHub account to use when no other account is specified. final String githubAccount = getEnv('ROD_GITHUB_ACCOUNT', 'nyxx-discord'); diff --git a/lib/src/web_app/api_server.dart b/lib/src/web_app/api_server.dart index c246006..9c2254e 100644 --- a/lib/src/web_app/api_server.dart +++ b/lib/src/web_app/api_server.dart @@ -8,6 +8,7 @@ import 'package:nyxx/nyxx.dart'; import 'package:running_on_dart/running_on_dart.dart'; import 'package:running_on_dart/src/web_app/jwt.dart'; import 'package:running_on_dart/src/web_app/mapper/guild_mapper.dart'; +import 'package:running_on_dart/src/web_app/mapper/tags_mapper.dart'; import 'package:running_on_dart/src/web_app/utils.dart'; import 'package:running_on_dart/src/services/bot_info.dart'; import 'package:shelf_cors_headers/shelf_cors_headers.dart'; @@ -34,6 +35,20 @@ class WebServer { return createOkResponse(guildData); } + Future _handleGuildTags(shelf.Request request) async { + final guildParam = request.params['id']; + if (guildParam == null) { + return createBadRequestResponse("Missing id param"); + } + + final searchQuery = request.requestedUri.queryParameters['query']; + final tagsLimit = int.tryParse(request.requestedUri.queryParameters['perPage'] ?? '5') ?? 5; + + return createOkResponse( + await mapGuildTagsToData(Snowflake.parse(guildParam), tagsLimit, searchQuery: searchQuery).toList(), + ); + } + Future _handleGuildDetails(shelf.Request request) async { final client = Injector.appInstance.get(); @@ -43,20 +58,13 @@ class WebServer { } final channelsLimit = int.tryParse(request.requestedUri.queryParameters['channelsLimit'] ?? '0') ?? 0; - final rolesLimit = int.tryParse(request.requestedUri.queryParameters['rolesLimit'] ?? '0')?? 0; + final rolesLimit = int.tryParse(request.requestedUri.queryParameters['rolesLimit'] ?? '0') ?? 0; final tagsLimit = int.tryParse(request.requestedUri.queryParameters['tagsLimit'] ?? '5') ?? 5; try { final guild = await client.guilds.get(Snowflake.parse(guildParam)); - return createOkResponse( - await mapGuildToDetailsData( - guild, - channelsLimit, - rolesLimit, - tagsLimit - ) - ); + return createOkResponse(await mapGuildToDetailsData(guild, channelsLimit, rolesLimit, tagsLimit)); } on HttpResponseError { return createNotFoundResponse(); } @@ -126,6 +134,7 @@ class WebServer { ..get("/api/guilds", _requireJwt(_handleGuilds, [JwtPermission.guilds])) // ..get("/api/guilds/", _requireJwt(_handleGuildDetails, [JwtPermission.guilds])) ..get("/api/guilds/", _handleGuildDetails) + ..get("/api/guilds//tags", _handleGuildTags) ..get("/api/validate-oauth", _handleValidateCode) ..all(r"/", staticHandler) ..all("/", _handleIndex); @@ -142,8 +151,12 @@ class WebServer { final router = await _setupRouter(); - final app = - const shelf.Pipeline().addMiddleware(shelf.logRequests()).addMiddleware(corsHeaders()).addHandler(router.call); + final corsChecker = dev ? originAllowAll : originOneOf(webServerAllowedOrigins.split(',')); + + final app = const shelf.Pipeline() + .addMiddleware(shelf.logRequests()) + .addMiddleware(corsHeaders(originChecker: corsChecker)) + .addHandler(router.call); _logger.info("Starting server at: http://$webServerHost:$webServerPort/"); await shelf_io.serve(app, webServerHost, webServerPort); diff --git a/lib/src/web_app/mapper/tags_mapper.dart b/lib/src/web_app/mapper/tags_mapper.dart index 845ca7c..ff8b056 100644 --- a/lib/src/web_app/mapper/tags_mapper.dart +++ b/lib/src/web_app/mapper/tags_mapper.dart @@ -3,10 +3,15 @@ import 'package:nyxx/nyxx.dart'; import 'package:running_on_dart/src/modules/tag.dart'; import 'package:running_on_dart/src/web_app/utils.dart'; -Stream mapGuildTagsToData(Snowflake guildId, int tagsLimit) async* { +Stream mapGuildTagsToData(Snowflake guildId, int tagsLimit, {String? searchQuery}) async* { final tagsModule = Injector.appInstance.get(); - for (final tag in tagsModule.getGuildTags(guildId).take(tagsLimit)) { + var tags = tagsModule.getGuildTags(guildId); + if (searchQuery != null) { + tags = tags.where((tag) => tag.name.contains(searchQuery)); + } + + for (final tag in tags.take(tagsLimit)) { yield { 'id': tag.id, 'name': tag.name,