Compare commits
6 commits
80385893c7
...
546e900204
Author | SHA1 | Date | |
---|---|---|---|
546e900204 | |||
bd21eeebcf | |||
05913a3b2f | |||
1fb1d8dd14 | |||
ddd96e415a | |||
397ffc2d5e |
31 changed files with 778 additions and 359 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -6,6 +6,8 @@ config.ini
|
||||||
*.DotSettings.user
|
*.DotSettings.user
|
||||||
proxy-config.json
|
proxy-config.json
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.idea/.idea.Foxnouns.NET/.idea/dataSources.xml
|
||||||
|
.idea/.idea.Foxnouns.NET/.idea/sqldialects.xml
|
||||||
|
|
||||||
docker/config.ini
|
docker/config.ini
|
||||||
docker/proxy-config.json
|
docker/proxy-config.json
|
||||||
|
|
|
@ -4,14 +4,31 @@
|
||||||
{
|
{
|
||||||
"name": "run-prettier",
|
"name": "run-prettier",
|
||||||
"command": "pnpm",
|
"command": "pnpm",
|
||||||
"args": ["format"],
|
"args": [
|
||||||
|
"prettier",
|
||||||
|
"-w",
|
||||||
|
"${staged}"
|
||||||
|
],
|
||||||
|
"include": [
|
||||||
|
"Foxnouns.Frontend/**/*.ts",
|
||||||
|
"Foxnouns.Frontend/**/*.json",
|
||||||
|
"Foxnouns.Frontend/**/*.scss",
|
||||||
|
"Foxnouns.Frontend/**/*.js",
|
||||||
|
"Foxnouns.Frontend/**/*.svelte"
|
||||||
|
],
|
||||||
|
"cwd": "Foxnouns.Frontend/",
|
||||||
"pathMode": "absolute"
|
"pathMode": "absolute"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "run-csharpier",
|
"name": "run-csharpier",
|
||||||
"command": "dotnet",
|
"command": "dotnet",
|
||||||
"args": [ "csharpier", "${staged}" ],
|
"args": [
|
||||||
"include": [ "**/*.cs" ]
|
"csharpier",
|
||||||
|
"${staged}"
|
||||||
|
],
|
||||||
|
"include": [
|
||||||
|
"**/*.cs"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Dto;
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
@ -49,6 +50,8 @@ public class ReportsController(
|
||||||
[FromBody] CreateReportRequest req
|
[FromBody] CreateReportRequest req
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
ValidationUtils.Validate([("context", ValidationUtils.ValidateReportContext(req.Context))]);
|
||||||
|
|
||||||
User target = await db.ResolveUserAsync(id);
|
User target = await db.ResolveUserAsync(id);
|
||||||
|
|
||||||
if (target.Id == CurrentUser!.Id)
|
if (target.Id == CurrentUser!.Id)
|
||||||
|
@ -96,6 +99,7 @@ public class ReportsController(
|
||||||
TargetUserId = target.Id,
|
TargetUserId = target.Id,
|
||||||
TargetMemberId = null,
|
TargetMemberId = null,
|
||||||
Reason = req.Reason,
|
Reason = req.Reason,
|
||||||
|
Context = req.Context,
|
||||||
TargetType = ReportTargetType.User,
|
TargetType = ReportTargetType.User,
|
||||||
TargetSnapshot = snapshot,
|
TargetSnapshot = snapshot,
|
||||||
};
|
};
|
||||||
|
@ -112,6 +116,8 @@ public class ReportsController(
|
||||||
[FromBody] CreateReportRequest req
|
[FromBody] CreateReportRequest req
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
ValidationUtils.Validate([("context", ValidationUtils.ValidateReportContext(req.Context))]);
|
||||||
|
|
||||||
Member target = await db.ResolveMemberAsync(id);
|
Member target = await db.ResolveMemberAsync(id);
|
||||||
|
|
||||||
if (target.User.Id == CurrentUser!.Id)
|
if (target.User.Id == CurrentUser!.Id)
|
||||||
|
@ -158,6 +164,7 @@ public class ReportsController(
|
||||||
TargetUserId = target.User.Id,
|
TargetUserId = target.User.Id,
|
||||||
TargetMemberId = target.Id,
|
TargetMemberId = target.Id,
|
||||||
Reason = req.Reason,
|
Reason = req.Reason,
|
||||||
|
Context = req.Context,
|
||||||
TargetType = ReportTargetType.Member,
|
TargetType = ReportTargetType.Member,
|
||||||
TargetSnapshot = snapshot,
|
TargetSnapshot = snapshot,
|
||||||
};
|
};
|
||||||
|
|
|
@ -108,6 +108,12 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
|
||||||
.HasFilter("fediverse_application_id IS NULL")
|
.HasFilter("fediverse_application_id IS NULL")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder
|
||||||
|
.Entity<AuditLogEntry>()
|
||||||
|
.HasOne(e => e.Report)
|
||||||
|
.WithOne(e => e.AuditLogEntry)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
modelBuilder.Entity<User>().Property(u => u.Sid).HasDefaultValueSql("find_free_user_sid()");
|
modelBuilder.Entity<User>().Property(u => u.Sid).HasDefaultValueSql("find_free_user_sid()");
|
||||||
modelBuilder.Entity<User>().Property(u => u.Fields).HasColumnType("jsonb");
|
modelBuilder.Entity<User>().Property(u => u.Fields).HasColumnType("jsonb");
|
||||||
modelBuilder.Entity<User>().Property(u => u.Names).HasColumnType("jsonb");
|
modelBuilder.Entity<User>().Property(u => u.Names).HasColumnType("jsonb");
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
[DbContext(typeof(DatabaseContext))]
|
||||||
|
[Migration("20241218195457_AddContextToReports")]
|
||||||
|
public partial class AddContextToReports : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "context",
|
||||||
|
table: "reports",
|
||||||
|
type: "text",
|
||||||
|
nullable: true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(name: "context", table: "reports");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
[DbContext(typeof(DatabaseContext))]
|
||||||
|
[Migration("20241218201855_MakeAuditLogReportsNullable")]
|
||||||
|
public partial class MakeAuditLogReportsNullable : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "fk_audit_log_reports_report_id",
|
||||||
|
table: "audit_log"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(name: "ix_audit_log_report_id", table: "audit_log");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_audit_log_report_id",
|
||||||
|
table: "audit_log",
|
||||||
|
column: "report_id",
|
||||||
|
unique: true
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "fk_audit_log_reports_report_id",
|
||||||
|
table: "audit_log",
|
||||||
|
column: "report_id",
|
||||||
|
principalTable: "reports",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.SetNull
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "fk_audit_log_reports_report_id",
|
||||||
|
table: "audit_log"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(name: "ix_audit_log_report_id", table: "audit_log");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_audit_log_report_id",
|
||||||
|
table: "audit_log",
|
||||||
|
column: "report_id"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "fk_audit_log_reports_report_id",
|
||||||
|
table: "audit_log",
|
||||||
|
column: "report_id",
|
||||||
|
principalTable: "reports",
|
||||||
|
principalColumn: "id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -113,6 +113,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasName("pk_audit_log");
|
.HasName("pk_audit_log");
|
||||||
|
|
||||||
b.HasIndex("ReportId")
|
b.HasIndex("ReportId")
|
||||||
|
.IsUnique()
|
||||||
.HasDatabaseName("ix_audit_log_report_id");
|
.HasDatabaseName("ix_audit_log_report_id");
|
||||||
|
|
||||||
b.ToTable("audit_log", (string)null);
|
b.ToTable("audit_log", (string)null);
|
||||||
|
@ -409,6 +410,10 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasColumnType("bigint")
|
.HasColumnType("bigint")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("Context")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("context");
|
||||||
|
|
||||||
b.Property<int>("Reason")
|
b.Property<int>("Reason")
|
||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("reason");
|
.HasColumnName("reason");
|
||||||
|
@ -675,8 +680,9 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Foxnouns.Backend.Database.Models.Report", "Report")
|
b.HasOne("Foxnouns.Backend.Database.Models.Report", "Report")
|
||||||
.WithMany()
|
.WithOne("AuditLogEntry")
|
||||||
.HasForeignKey("ReportId")
|
.HasForeignKey("Foxnouns.Backend.Database.Models.AuditLogEntry", "ReportId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull)
|
||||||
.HasConstraintName("fk_audit_log_reports_report_id");
|
.HasConstraintName("fk_audit_log_reports_report_id");
|
||||||
|
|
||||||
b.Navigation("Report");
|
b.Navigation("Report");
|
||||||
|
@ -839,6 +845,11 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
b.Navigation("ProfileFlags");
|
b.Navigation("ProfileFlags");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("AuditLogEntry");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("AuthMethods");
|
b.Navigation("AuthMethods");
|
||||||
|
|
|
@ -12,6 +12,7 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
|
|
@ -29,9 +29,12 @@ public class Report : BaseModel
|
||||||
|
|
||||||
public ReportStatus Status { get; set; }
|
public ReportStatus Status { get; set; }
|
||||||
public ReportReason Reason { get; init; }
|
public ReportReason Reason { get; init; }
|
||||||
|
public string? Context { get; init; }
|
||||||
|
|
||||||
public ReportTargetType TargetType { get; init; }
|
public ReportTargetType TargetType { get; init; }
|
||||||
public string? TargetSnapshot { get; init; }
|
public string? TargetSnapshot { get; init; }
|
||||||
|
|
||||||
|
public AuditLogEntry? AuditLogEntry { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
|
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
|
||||||
|
|
|
@ -57,7 +57,7 @@ public record NotificationResponse(
|
||||||
|
|
||||||
public record AuditLogEntity(Snowflake Id, string Username);
|
public record AuditLogEntity(Snowflake Id, string Username);
|
||||||
|
|
||||||
public record CreateReportRequest(ReportReason Reason);
|
public record CreateReportRequest(ReportReason Reason, string? Context = null);
|
||||||
|
|
||||||
public record IgnoreReportRequest(string? Reason = null);
|
public record IgnoreReportRequest(string? Reason = null);
|
||||||
|
|
||||||
|
|
|
@ -196,6 +196,13 @@ public static partial class ValidationUtils
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public const int MaximumReportContextLength = 512;
|
||||||
|
|
||||||
|
public static ValidationError? ValidateReportContext(string? context) =>
|
||||||
|
context?.Length > MaximumReportContextLength
|
||||||
|
? ValidationError.GenericValidationError("Avatar is too large", null)
|
||||||
|
: null;
|
||||||
|
|
||||||
public const int MinimumPasswordLength = 12;
|
public const int MinimumPasswordLength = 12;
|
||||||
public const int MaximumPasswordLength = 1024;
|
public const int MaximumPasswordLength = 1024;
|
||||||
|
|
||||||
|
|
|
@ -13,8 +13,8 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.2.10",
|
"@sveltejs/adapter-node": "^5.2.10",
|
||||||
"@sveltejs/kit": "^2.11.1",
|
"@sveltejs/kit": "^2.12.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.2",
|
||||||
"@sveltestrap/sveltestrap": "^6.2.7",
|
"@sveltestrap/sveltestrap": "^6.2.7",
|
||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
|
@ -28,13 +28,13 @@
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-svelte": "^3.3.2",
|
"prettier-plugin-svelte": "^3.3.2",
|
||||||
"sass": "^1.83.0",
|
"sass": "^1.83.0",
|
||||||
"svelte": "^5.13.0",
|
"svelte": "^5.14.3",
|
||||||
"svelte-bootstrap-icons": "^3.1.1",
|
"svelte-bootstrap-icons": "^3.1.1",
|
||||||
"svelte-check": "^4.1.1",
|
"svelte-check": "^4.1.1",
|
||||||
"sveltekit-i18n": "^2.4.2",
|
"sveltekit-i18n": "^2.4.2",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"typescript-eslint": "^8.18.0",
|
"typescript-eslint": "^8.18.1",
|
||||||
"vite": "^5.4.11"
|
"vite": "^6.0.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c",
|
"packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
File diff suppressed because it is too large
Load diff
9
Foxnouns.Frontend/src/app.d.ts
vendored
9
Foxnouns.Frontend/src/app.d.ts
vendored
|
@ -1,7 +1,16 @@
|
||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
|
||||||
|
import type { ErrorCode } from "$api/error";
|
||||||
|
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
|
interface Error {
|
||||||
|
message: string;
|
||||||
|
status: number;
|
||||||
|
code: ErrorCode;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
// interface Locals {}
|
// interface Locals {}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
|
import ApiError, { ErrorCode } from "$api/error";
|
||||||
import { PRIVATE_API_HOST, PRIVATE_INTERNAL_API_HOST } from "$env/static/private";
|
import { PRIVATE_API_HOST, PRIVATE_INTERNAL_API_HOST } from "$env/static/private";
|
||||||
import { PUBLIC_API_BASE } from "$env/static/public";
|
import { PUBLIC_API_BASE } from "$env/static/public";
|
||||||
import type { HandleFetch } from "@sveltejs/kit";
|
import log from "$lib/log";
|
||||||
|
import type { HandleFetch, HandleServerError } from "@sveltejs/kit";
|
||||||
|
|
||||||
export const handleFetch: HandleFetch = async ({ request, fetch }) => {
|
export const handleFetch: HandleFetch = async ({ request, fetch }) => {
|
||||||
if (request.url.startsWith(`${PUBLIC_API_BASE}/internal`)) {
|
if (request.url.startsWith(`${PUBLIC_API_BASE}/internal`)) {
|
||||||
|
@ -11,3 +13,24 @@ export const handleFetch: HandleFetch = async ({ request, fetch }) => {
|
||||||
|
|
||||||
return await fetch(request);
|
return await fetch(request);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const handleError: HandleServerError = async ({ error, status, message }) => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
status: error.raw?.status || status,
|
||||||
|
message: error.raw?.message || "Unknown error",
|
||||||
|
code: error.code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status >= 400 && status <= 499) {
|
||||||
|
return { id, status, message, code: ErrorCode.GenericApiError };
|
||||||
|
}
|
||||||
|
|
||||||
|
log.error("[%s] error in handler:", id, error);
|
||||||
|
|
||||||
|
return { id, status, message, code: ErrorCode.InternalServerError };
|
||||||
|
};
|
||||||
|
|
|
@ -9,7 +9,7 @@ export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
/**
|
/**
|
||||||
* Optional arguments for a request. `load` and `action` functions should always pass `fetch` and `cookies`.
|
* Optional arguments for a request. `load` and `action` functions should always pass `fetch` and `cookies`.
|
||||||
*/
|
*/
|
||||||
export type RequestArgs = {
|
export type RequestArgs<T> = {
|
||||||
/**
|
/**
|
||||||
* The token for this request. Where possible, `cookies` should be passed instead.
|
* The token for this request. Where possible, `cookies` should be passed instead.
|
||||||
* Will override `cookies` if both are passed.
|
* Will override `cookies` if both are passed.
|
||||||
|
@ -23,7 +23,7 @@ export type RequestArgs = {
|
||||||
/**
|
/**
|
||||||
* The body for this request, which will be serialized to JSON. Should be a plain JS object.
|
* The body for this request, which will be serialized to JSON. Should be a plain JS object.
|
||||||
*/
|
*/
|
||||||
body?: unknown;
|
body?: T;
|
||||||
/**
|
/**
|
||||||
* The fetch function to use. Should be passed in loader and action functions, but can be safely ignored for client-side requests.
|
* The fetch function to use. Should be passed in loader and action functions, but can be safely ignored for client-side requests.
|
||||||
*/
|
*/
|
||||||
|
@ -41,10 +41,10 @@ export type RequestArgs = {
|
||||||
* @param args Optional arguments to the request function.
|
* @param args Optional arguments to the request function.
|
||||||
* @returns A Response object.
|
* @returns A Response object.
|
||||||
*/
|
*/
|
||||||
export async function baseRequest(
|
export async function baseRequest<T = unknown>(
|
||||||
method: Method,
|
method: Method,
|
||||||
path: string,
|
path: string,
|
||||||
args: RequestArgs = {},
|
args: RequestArgs<T> = {},
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const token = args.token ?? args.cookies?.get(TOKEN_COOKIE_NAME);
|
const token = args.token ?? args.cookies?.get(TOKEN_COOKIE_NAME);
|
||||||
|
|
||||||
|
@ -72,11 +72,11 @@ export async function baseRequest(
|
||||||
* @param args Optional arguments to the request function.
|
* @param args Optional arguments to the request function.
|
||||||
* @returns The response deserialized as `T`.
|
* @returns The response deserialized as `T`.
|
||||||
*/
|
*/
|
||||||
export async function apiRequest<T>(
|
export async function apiRequest<TResponse, TRequest = unknown>(
|
||||||
method: Method,
|
method: Method,
|
||||||
path: string,
|
path: string,
|
||||||
args: RequestArgs = {},
|
args: RequestArgs<TRequest> = {},
|
||||||
): Promise<T> {
|
): Promise<TResponse> {
|
||||||
const resp = await baseRequest(method, path, args);
|
const resp = await baseRequest(method, path, args);
|
||||||
|
|
||||||
if (resp.status < 200 || resp.status > 299) {
|
if (resp.status < 200 || resp.status > 299) {
|
||||||
|
@ -84,7 +84,7 @@ export async function apiRequest<T>(
|
||||||
if ("code" in err) throw new ApiError(err);
|
if ("code" in err) throw new ApiError(err);
|
||||||
else throw new ApiError();
|
else throw new ApiError();
|
||||||
}
|
}
|
||||||
return (await resp.json()) as T;
|
return (await resp.json()) as TResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -94,10 +94,10 @@ export async function apiRequest<T>(
|
||||||
* @param args Optional arguments to the request function.
|
* @param args Optional arguments to the request function.
|
||||||
* @param enforce204 Whether to throw an error on a non-204 status code.
|
* @param enforce204 Whether to throw an error on a non-204 status code.
|
||||||
*/
|
*/
|
||||||
export async function fastRequest(
|
export async function fastRequest<T = unknown>(
|
||||||
method: Method,
|
method: Method,
|
||||||
path: string,
|
path: string,
|
||||||
args: RequestArgs = {},
|
args: RequestArgs<T> = {},
|
||||||
enforce204: boolean = false,
|
enforce204: boolean = false,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const resp = await baseRequest(method, path, args);
|
const resp = await baseRequest(method, path, args);
|
||||||
|
|
26
Foxnouns.Frontend/src/lib/api/models/moderation.ts
Normal file
26
Foxnouns.Frontend/src/lib/api/models/moderation.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
export type CreateReportRequest = {
|
||||||
|
reason: ReportReason;
|
||||||
|
context: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum ReportReason {
|
||||||
|
Totalitarianism = "TOTALITARIANISM",
|
||||||
|
HateSpeech = "HATE_SPEECH",
|
||||||
|
Racism = "RACISM",
|
||||||
|
Homophobia = "HOMOPHOBIA",
|
||||||
|
Transphobia = "TRANSPHOBIA",
|
||||||
|
Queerphobia = "QUEERPHOBIA",
|
||||||
|
Exclusionism = "EXCLUSIONISM",
|
||||||
|
Sexism = "SEXISM",
|
||||||
|
Ableism = "ABLEISM",
|
||||||
|
ChildPornography = "CHILD_PORNOGRAPHY",
|
||||||
|
PedophiliaAdvocacy = "PEDOPHILIA_ADVOCACY",
|
||||||
|
Harassment = "HARASSMENT",
|
||||||
|
Impersonation = "IMPERSONATION",
|
||||||
|
Doxxing = "DOXXING",
|
||||||
|
EncouragingSelfHarm = "ENCOURAGING_SELF_HARM",
|
||||||
|
Spam = "SPAM",
|
||||||
|
Trolling = "TROLLING",
|
||||||
|
Advertisement = "ADVERTISEMENT",
|
||||||
|
CopyrightViolation = "COPYRIGHT_VIOLATION",
|
||||||
|
}
|
|
@ -8,7 +8,7 @@
|
||||||
NavLink,
|
NavLink,
|
||||||
NavItem,
|
NavItem,
|
||||||
} from "@sveltestrap/sveltestrap";
|
} from "@sveltestrap/sveltestrap";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import type { Meta, MeUser } from "$api/models/index";
|
import type { Meta, MeUser } from "$api/models/index";
|
||||||
import Logo from "$components/Logo.svelte";
|
import Logo from "$components/Logo.svelte";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
|
@ -51,19 +51,19 @@
|
||||||
<NavItem>
|
<NavItem>
|
||||||
<NavLink
|
<NavLink
|
||||||
href="/@{user.username}"
|
href="/@{user.username}"
|
||||||
active={$page.url.pathname.startsWith(`/@${user.username}`)}
|
active={page.url.pathname.startsWith(`/@${user.username}`)}
|
||||||
>
|
>
|
||||||
@{user.username}
|
@{user.username}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
<NavItem>
|
<NavItem>
|
||||||
<NavLink href="/settings" active={$page.url.pathname.startsWith("/settings")}>
|
<NavLink href="/settings" active={page.url.pathname.startsWith("/settings")}>
|
||||||
{$t("nav.settings")}
|
{$t("nav.settings")}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
{:else}
|
{:else}
|
||||||
<NavItem>
|
<NavItem>
|
||||||
<NavLink href="/auth/log-in" active={$page.url.pathname === "/auth/log-in"}>
|
<NavLink href="/auth/log-in" active={page.url.pathname === "/auth/log-in"}>
|
||||||
{$t("nav.log-in")}
|
{$t("nav.log-in")}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
|
||||||
|
type Props = { required?: boolean };
|
||||||
|
let { required }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if required}
|
||||||
|
<small class="text-danger"><abbr title={$t("form.required")}>*</abbr></small>
|
||||||
|
{:else}
|
||||||
|
<small class="text-body-secondary">{$t("form.optional")}</small>
|
||||||
|
{/if}
|
|
@ -0,0 +1,36 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { MeUser } from "$api/models";
|
||||||
|
import { PUBLIC_BASE_URL, PUBLIC_SHORT_URL } from "$env/static/public";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
user: string;
|
||||||
|
member?: string;
|
||||||
|
sid: string;
|
||||||
|
reportUrl: string;
|
||||||
|
meUser: MeUser | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { user, member, sid, reportUrl, meUser }: Props = $props();
|
||||||
|
|
||||||
|
let profileUrl = $derived(
|
||||||
|
member ? `${PUBLIC_BASE_URL}/@${user}/${member}` : `${PUBLIC_BASE_URL}/@${user}`,
|
||||||
|
);
|
||||||
|
let shortUrl = $derived(`${PUBLIC_SHORT_URL}/${sid}`);
|
||||||
|
|
||||||
|
const copyUrl = async (url: string) => {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick={() => copyUrl(profileUrl)}>
|
||||||
|
{$t("profile.copy-link-button")}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick={() => copyUrl(shortUrl)}>
|
||||||
|
{$t("profile.copy-short-link-button")}
|
||||||
|
</button>
|
||||||
|
{#if meUser && meUser.username !== user}
|
||||||
|
<a class="btn btn-outline-danger" href={reportUrl}>{$t("profile.report-button")}</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
|
@ -18,7 +18,10 @@
|
||||||
"pronouns-header": "Pronouns",
|
"pronouns-header": "Pronouns",
|
||||||
"default-members-header": "Members",
|
"default-members-header": "Members",
|
||||||
"create-member-button": "Create member",
|
"create-member-button": "Create member",
|
||||||
"back-to-user": "Back to {{name}}"
|
"back-to-user": "Back to {{name}}",
|
||||||
|
"copy-link-button": "Copy link",
|
||||||
|
"copy-short-link-button": "Copy short link",
|
||||||
|
"report-button": "Report profile"
|
||||||
},
|
},
|
||||||
"title": {
|
"title": {
|
||||||
"log-in": "Log in",
|
"log-in": "Log in",
|
||||||
|
@ -237,5 +240,35 @@
|
||||||
"custom-preference-muted": "Show as muted text",
|
"custom-preference-muted": "Show as muted text",
|
||||||
"custom-preference-favourite": "Treat like favourite"
|
"custom-preference-favourite": "Treat like favourite"
|
||||||
},
|
},
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel",
|
||||||
|
"report": {
|
||||||
|
"title": "Reporting {{name}}",
|
||||||
|
"totalitarianism": "Support of totalitarian regimes",
|
||||||
|
"hate-speech": "Hate speech",
|
||||||
|
"racism": "Racism or xenophobia",
|
||||||
|
"homophobia": "Homophobia",
|
||||||
|
"transphobia": "Transphobia",
|
||||||
|
"queerphobia": "Queerphobia (other)",
|
||||||
|
"exclusionism": "Queer or plural exclusionism",
|
||||||
|
"sexism": "Sexism or misogyny",
|
||||||
|
"ableism": "Ableism",
|
||||||
|
"child-pornography": "Child pornography",
|
||||||
|
"pedophilia-advocacy": "Pedophilia advocacy",
|
||||||
|
"harassment": "Harassment",
|
||||||
|
"impersonation": "Impersonation",
|
||||||
|
"doxxing": "Doxxing",
|
||||||
|
"encouraging-self-harm": "Encouraging self-harm or suicide",
|
||||||
|
"spam": "Spam",
|
||||||
|
"trolling": "Trolling",
|
||||||
|
"advertisement": "Advertising",
|
||||||
|
"copyright-violation": "Copyright or trademark violation",
|
||||||
|
"success": "Successfully submitted report!",
|
||||||
|
"reason-label": "Why are you reporting this profile?",
|
||||||
|
"context-label": "Is there any context you'd like to give us?",
|
||||||
|
"submit-button": "Submit report"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"optional": "(optional)",
|
||||||
|
"required": "Required"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
|
import Error from "$components/Error.svelte";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
import type { LayoutData } from "./$types";
|
import type { LayoutData } from "./$types";
|
||||||
|
|
||||||
|
@ -14,22 +15,8 @@
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h3>{$t("title.an-error-occurred")}</h3>
|
<Error {error} headerElem="h3" />
|
||||||
<p>
|
<div class="btn-group mt-2">
|
||||||
<strong>{$page.status}</strong>: {error.message}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{#if $page.status === 400}
|
|
||||||
{$t("error.400-description")}
|
|
||||||
{:else if $page.status === 404}
|
|
||||||
{$t("error.404-description")}
|
|
||||||
{:else if $page.status === 500}
|
|
||||||
{$t("error.500-description")}
|
|
||||||
{:else}
|
|
||||||
{$t("error.unknown-status-description")}
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
<div class="btn-group">
|
|
||||||
{#if data.meUser}
|
{#if data.meUser}
|
||||||
<a class="btn btn-primary" href="/@{data.meUser.username}">
|
<a class="btn btn-primary" href="/@{data.meUser.username}">
|
||||||
{$t("error.back-to-profile-button")}
|
{$t("error.back-to-profile-button")}
|
||||||
|
|
|
@ -1,25 +1,14 @@
|
||||||
import { apiRequest } from "$api";
|
import { apiRequest } from "$api";
|
||||||
import ApiError, { ErrorCode } from "$api/error.js";
|
|
||||||
import type { UserWithMembers } from "$api/models";
|
import type { UserWithMembers } from "$api/models";
|
||||||
import log from "$lib/log.js";
|
|
||||||
import paginate from "$lib/paginate";
|
import paginate from "$lib/paginate";
|
||||||
import { error } from "@sveltejs/kit";
|
|
||||||
|
|
||||||
const MEMBERS_PER_PAGE = 20;
|
const MEMBERS_PER_PAGE = 20;
|
||||||
|
|
||||||
export const load = async ({ params, fetch, cookies, url }) => {
|
export const load = async ({ params, fetch, cookies, url }) => {
|
||||||
let user: UserWithMembers;
|
const user = await apiRequest<UserWithMembers>("GET", `/users/${params.username}`, {
|
||||||
|
fetch,
|
||||||
try {
|
cookies,
|
||||||
user = await apiRequest<UserWithMembers>("GET", `/users/${params.username}`, {
|
});
|
||||||
fetch,
|
|
||||||
cookies,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ApiError && e.code === ErrorCode.UserNotFound) error(404, "User not found");
|
|
||||||
log.error("Error fetching user %s:", params.username, e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, currentPage, pageCount } = paginate(
|
const { data, currentPage, pageCount } = paginate(
|
||||||
user.members,
|
user.members,
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
import { Icon } from "@sveltestrap/sveltestrap";
|
import { Icon } from "@sveltestrap/sveltestrap";
|
||||||
import Paginator from "$components/Paginator.svelte";
|
import Paginator from "$components/Paginator.svelte";
|
||||||
import MemberCard from "$components/profile/user/MemberCard.svelte";
|
import MemberCard from "$components/profile/user/MemberCard.svelte";
|
||||||
|
import ProfileButtons from "$components/profile/ProfileButtons.svelte";
|
||||||
|
|
||||||
type Props = { data: PageData };
|
type Props = { data: PageData };
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
@ -28,6 +29,13 @@
|
||||||
<ProfileHeader name="@{data.user.username}" profile={data.user} offset={data.user.utc_offset} />
|
<ProfileHeader name="@{data.user.username}" profile={data.user} offset={data.user.utc_offset} />
|
||||||
<ProfileFields profile={data.user} {allPreferences} />
|
<ProfileFields profile={data.user} {allPreferences} />
|
||||||
|
|
||||||
|
<ProfileButtons
|
||||||
|
meUser={data.meUser}
|
||||||
|
user={data.user.username}
|
||||||
|
sid={data.user.sid}
|
||||||
|
reportUrl="/report/{data.user.id}"
|
||||||
|
/>
|
||||||
|
|
||||||
{#if data.members.length > 0}
|
{#if data.members.length > 0}
|
||||||
<hr />
|
<hr />
|
||||||
<h2>
|
<h2>
|
||||||
|
|
|
@ -1,28 +1,15 @@
|
||||||
import { apiRequest } from "$api";
|
import { apiRequest } from "$api";
|
||||||
import ApiError, { ErrorCode } from "$api/error.js";
|
|
||||||
import type { Member } from "$api/models/member";
|
import type { Member } from "$api/models/member";
|
||||||
import log from "$lib/log.js";
|
|
||||||
import { error } from "@sveltejs/kit";
|
|
||||||
|
|
||||||
export const load = async ({ params, fetch, cookies }) => {
|
export const load = async ({ params, fetch, cookies }) => {
|
||||||
try {
|
const member = await apiRequest<Member>(
|
||||||
const member = await apiRequest<Member>(
|
"GET",
|
||||||
"GET",
|
`/users/${params.username}/members/${params.memberName}`,
|
||||||
`/users/${params.username}/members/${params.memberName}`,
|
{
|
||||||
{
|
fetch,
|
||||||
fetch,
|
cookies,
|
||||||
cookies,
|
},
|
||||||
},
|
);
|
||||||
);
|
|
||||||
|
|
||||||
return { member };
|
return { member };
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ApiError) {
|
|
||||||
if (e.code === ErrorCode.UserNotFound) error(404, "User not found");
|
|
||||||
if (e.code === ErrorCode.MemberNotFound) error(404, "Member not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
log.error("Error fetching user %s/member %s:", params.username, params.memberName, e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
import ProfileFields from "$components/profile/ProfileFields.svelte";
|
import ProfileFields from "$components/profile/ProfileFields.svelte";
|
||||||
import { Icon } from "@sveltestrap/sveltestrap";
|
import { Icon } from "@sveltestrap/sveltestrap";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
|
import ProfileButtons from "$components/profile/ProfileButtons.svelte";
|
||||||
|
|
||||||
type Props = { data: PageData };
|
type Props = { data: PageData };
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
@ -37,4 +38,12 @@
|
||||||
|
|
||||||
<ProfileHeader name="{data.member.name} (@{data.member.user.username})" profile={data.member} />
|
<ProfileHeader name="{data.member.name} (@{data.member.user.username})" profile={data.member} />
|
||||||
<ProfileFields profile={data.member} {allPreferences} />
|
<ProfileFields profile={data.member} {allPreferences} />
|
||||||
|
|
||||||
|
<ProfileButtons
|
||||||
|
meUser={data.meUser}
|
||||||
|
user={data.member.user.username}
|
||||||
|
member={data.member.name}
|
||||||
|
sid={data.member.sid}
|
||||||
|
reportUrl="/report/{data.member.user.id}?member={data.member.id}"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
60
Foxnouns.Frontend/src/routes/report/[id]/+page.server.ts
Normal file
60
Foxnouns.Frontend/src/routes/report/[id]/+page.server.ts
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
import { apiRequest, fastRequest } from "$api";
|
||||||
|
import ApiError from "$api/error.js";
|
||||||
|
import type { Member } from "$api/models/member.js";
|
||||||
|
import { type CreateReportRequest, ReportReason } from "$api/models/moderation.js";
|
||||||
|
import type { PartialUser, User } from "$api/models/user.js";
|
||||||
|
import log from "$lib/log.js";
|
||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export const load = async ({ parent, params, fetch, cookies, url }) => {
|
||||||
|
const { meUser } = await parent();
|
||||||
|
if (!meUser) redirect(303, "/");
|
||||||
|
|
||||||
|
let user: PartialUser;
|
||||||
|
let member: Member | null = null;
|
||||||
|
if (url.searchParams.has("member")) {
|
||||||
|
const resp = await apiRequest<Member>(
|
||||||
|
"GET",
|
||||||
|
`/users/${params.id}/members/${url.searchParams.get("member")}`,
|
||||||
|
{ fetch, cookies },
|
||||||
|
);
|
||||||
|
|
||||||
|
user = resp.user;
|
||||||
|
member = resp;
|
||||||
|
} else {
|
||||||
|
user = await apiRequest<User>("GET", `/users/${params.id}`, { fetch, cookies });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meUser.id === user.id) redirect(303, "/");
|
||||||
|
|
||||||
|
return { user, member };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
default: async ({ request, fetch, cookies }) => {
|
||||||
|
const body = await request.formData();
|
||||||
|
|
||||||
|
const targetIsMember = body.get("target-type") === "member";
|
||||||
|
const target = body.get("target-id") as string;
|
||||||
|
const reason = body.get("reason") as ReportReason;
|
||||||
|
const context = body.get("context") as string | null;
|
||||||
|
|
||||||
|
const url = targetIsMember
|
||||||
|
? `/moderation/report-member/${target}`
|
||||||
|
: `/moderation/report-user/${target}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fastRequest<CreateReportRequest>("POST", url, {
|
||||||
|
body: { reason, context },
|
||||||
|
fetch,
|
||||||
|
cookies,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, error: null };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) return { ok: false, error: e.obj };
|
||||||
|
log.error("error reporting user or member %s:", target, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
72
Foxnouns.Frontend/src/routes/report/[id]/+page.svelte
Normal file
72
Foxnouns.Frontend/src/routes/report/[id]/+page.svelte
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { ReportReason } from "$api/models/moderation";
|
||||||
|
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
|
||||||
|
import RequiredFieldMarker from "$components/RequiredFieldMarker.svelte";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import type { ActionData, PageData } from "./$types";
|
||||||
|
|
||||||
|
type Props = { data: PageData; form: ActionData };
|
||||||
|
let { data, form }: Props = $props();
|
||||||
|
|
||||||
|
let name = $derived(
|
||||||
|
data.member ? `${data.member.name} (@${data.user.username})` : "@" + data.user.username,
|
||||||
|
);
|
||||||
|
|
||||||
|
let link = $derived(
|
||||||
|
data.member ? `/@${data.user.username}/${data.member.name}` : `/@${data.user.username}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(data.user, !!data.member);
|
||||||
|
|
||||||
|
let reasons = $derived.by(() => {
|
||||||
|
const reasons = [];
|
||||||
|
for (const value of Object.values(ReportReason)) {
|
||||||
|
const key = "report." + value.toLowerCase().replaceAll("_", "-");
|
||||||
|
reasons.push({ key, value });
|
||||||
|
}
|
||||||
|
return reasons;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$t("report.title", { name })} • pronouns.cc</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<form method="POST" class="w-lg-75 mx-auto">
|
||||||
|
<h3>{$t("report.title", { name })}</h3>
|
||||||
|
<FormStatusMarker {form} successMessage={$t("report.success")} />
|
||||||
|
<input type="hidden" name="target-type" value={data.member ? "member" : "user"} />
|
||||||
|
<input type="hidden" name="target-id" value={data.member ? data.member.id : data.user.id} />
|
||||||
|
|
||||||
|
<h4 class="mt-3">{$t("report.reason-label")} <RequiredFieldMarker required /></h4>
|
||||||
|
<div class="row row-cols-1 row-cols-lg-2">
|
||||||
|
{#each reasons as reason}
|
||||||
|
<div class="col">
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="radio"
|
||||||
|
name="reason"
|
||||||
|
value={reason.value}
|
||||||
|
id="reason-{reason.value}"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="reason-{reason.value}">{$t(reason.key)}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="mt-3">
|
||||||
|
{$t("report.context-label")}
|
||||||
|
<RequiredFieldMarker />
|
||||||
|
</h4>
|
||||||
|
<textarea class="form-control" name="context" style="height: 100px;" maxlength={512}></textarea>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<button type="submit" class="btn btn-danger">{$t("report.submit-button")}</button>
|
||||||
|
<a href={link} class="btn btn-secondary">{$t("cancel")}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
import { Nav, NavLink } from "@sveltestrap/sveltestrap";
|
import { Nav, NavLink } from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
|
@ -10,11 +10,11 @@
|
||||||
const isActive = (path: string | string[], prefix: boolean = false) =>
|
const isActive = (path: string | string[], prefix: boolean = false) =>
|
||||||
typeof path === "string"
|
typeof path === "string"
|
||||||
? prefix
|
? prefix
|
||||||
? $page.url.pathname.startsWith(path)
|
? page.url.pathname.startsWith(path)
|
||||||
: $page.url.pathname === path
|
: page.url.pathname === path
|
||||||
: prefix
|
: prefix
|
||||||
? path.some((p) => $page.url.pathname.startsWith(p))
|
? path.some((p) => page.url.pathname.startsWith(p))
|
||||||
: path.some((p) => $page.url.pathname === p);
|
: path.some((p) => page.url.pathname === p);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
import type { LayoutData } from "./$types";
|
import type { LayoutData } from "./$types";
|
||||||
|
|
||||||
type Props = { data: LayoutData; children: Snippet };
|
type Props = { data: LayoutData; children: Snippet };
|
||||||
let { data, children }: Props = $props();
|
let { data, children }: Props = $props();
|
||||||
|
|
||||||
const isActive = (path: string) => $page.url.pathname === path;
|
const isActive = (path: string) => page.url.pathname === path;
|
||||||
|
|
||||||
let name = $derived(
|
let name = $derived(
|
||||||
data.member.display_name === data.member.name
|
data.member.display_name === data.member.name
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/state";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
import type { LayoutData } from "./$types";
|
import type { LayoutData } from "./$types";
|
||||||
|
|
||||||
type Props = { data: LayoutData; children: Snippet };
|
type Props = { data: LayoutData; children: Snippet };
|
||||||
let { data, children }: Props = $props();
|
let { data, children }: Props = $props();
|
||||||
|
|
||||||
const isActive = (path: string) => $page.url.pathname === path;
|
const isActive = (path: string) => page.url.pathname === path;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
Loading…
Reference in a new issue