diff --git a/src/app/hack/[slug]/actions.ts b/src/app/hack/[slug]/actions.ts
index 59e6c76..c82d1d4 100644
--- a/src/app/hack/[slug]/actions.ts
+++ b/src/app/hack/[slug]/actions.ts
@@ -1,11 +1,12 @@
"use server";
-import { createClient } from "@/utils/supabase/server";
+import { createClient, createServiceClient } from "@/utils/supabase/server";
import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server";
-import { isInformationalArchiveHack } from "@/utils/hack";
+import { isInformationalArchiveHack, canEditAsCreator } from "@/utils/hack";
import { sendDiscordMessageEmbed } from "@/utils/discord";
import { headers } from "next/headers";
import { validateEmail } from "@/utils/auth";
+import { revalidatePath } from "next/cache";
export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
const supabase = await createClient();
@@ -211,3 +212,371 @@ export async function submitHackReport(data: {
return { error: null };
}
+export async function getPatchDownloadUrl(patchId: number): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
+ const supabase = await createClient();
+
+ // Fetch patch info with parent_hack
+ const { data: patch, error: patchError } = await supabase
+ .from("patches")
+ .select("id, bucket, filename, published, archived, parent_hack")
+ .eq("id", patchId)
+ .maybeSingle();
+
+ if (patchError || !patch) {
+ return { ok: false, error: "Patch not found" };
+ }
+
+ // Only allow downloading published, non-archived patches (or if user is creator)
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!patch.published || patch.archived) {
+ if (!user) {
+ return { ok: false, error: "Unauthorized" };
+ }
+ // Check if user is creator
+ if (!patch.parent_hack) {
+ return { ok: false, error: "Unauthorized" };
+ }
+ const { data: hack } = await supabase
+ .from("hacks")
+ .select("created_by")
+ .eq("slug", patch.parent_hack)
+ .maybeSingle();
+
+ if (!hack || hack.created_by !== user.id) {
+ return { ok: false, error: "Unauthorized" };
+ }
+ }
+
+ try {
+ const client = getMinioClient();
+ const bucket = patch.bucket || PATCHES_BUCKET;
+ const signedUrl = await client.presignedGetObject(bucket, patch.filename, 60 * 5);
+ return { ok: true, url: signedUrl };
+ } catch (error) {
+ console.error("Error signing patch URL:", error);
+ return { ok: false, error: "Failed to generate download URL" };
+ }
+}
+
+export async function archivePatchVersion(slug: string, patchId: number): Promise<{ ok: true } | { ok: false; error: string }> {
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) return { ok: false, error: "Unauthorized" };
+
+ // Fetch hack and verify permissions
+ const { data: hack, error: hErr } = await supabase
+ .from("hacks")
+ .select("slug, created_by, current_patch, original_author")
+ .eq("slug", slug)
+ .maybeSingle();
+ if (hErr || !hack) return { ok: false, error: "Hack not found" };
+
+ if (!canEditAsCreator(hack, user.id)) {
+ return { ok: false, error: "Forbidden" };
+ }
+
+ // Cannot archive current_patch
+ if (hack.current_patch === patchId) {
+ return { ok: false, error: "Cannot archive the current patch version" };
+ }
+
+ // Verify patch belongs to this hack
+ const { data: patch, error: pErr } = await supabase
+ .from("patches")
+ .select("id, parent_hack")
+ .eq("id", patchId)
+ .maybeSingle();
+ if (pErr || !patch || patch.parent_hack !== slug) {
+ return { ok: false, error: "Patch not found" };
+ }
+
+ // Archive the patch
+ const serviceClient = await createServiceClient();
+ const { error: updateErr } = await serviceClient
+ .from("patches")
+ .update({ archived: true, archived_at: new Date().toISOString() })
+ .eq("id", patchId);
+
+ if (updateErr) return { ok: false, error: updateErr.message };
+
+ revalidatePath(`/hack/${slug}/versions`);
+ return { ok: true };
+}
+
+export async function restorePatchVersion(slug: string, patchId: number): Promise<{ ok: true } | { ok: false; error: string }> {
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) return { ok: false, error: "Unauthorized" };
+
+ // Fetch hack and verify permissions
+ const { data: hack, error: hErr } = await supabase
+ .from("hacks")
+ .select("slug, created_by, current_patch, original_author")
+ .eq("slug", slug)
+ .maybeSingle();
+ if (hErr || !hack) return { ok: false, error: "Hack not found" };
+
+ if (!canEditAsCreator({ created_by: hack.created_by, current_patch: hack.current_patch, original_author: hack.original_author }, user.id)) {
+ return { ok: false, error: "Forbidden" };
+ }
+
+ // Verify patch belongs to this hack
+ const { data: patch, error: pErr } = await supabase
+ .from("patches")
+ .select("id, parent_hack")
+ .eq("id", patchId)
+ .maybeSingle();
+ if (pErr || !patch || patch.parent_hack !== slug) {
+ return { ok: false, error: "Patch not found" };
+ }
+
+ // Restore the patch (un-archive)
+ const serviceClient = await createServiceClient();
+ const { error: updateErr } = await serviceClient
+ .from("patches")
+ .update({ archived: false, archived_at: null })
+ .eq("id", patchId);
+
+ if (updateErr) return { ok: false, error: updateErr.message };
+
+ revalidatePath(`/hack/${slug}/versions`);
+ return { ok: true };
+}
+
+export async function rollbackToVersion(slug: string, patchId: number): Promise<{ ok: true } | { ok: false; error: string }> {
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) return { ok: false, error: "Unauthorized" };
+
+ // Fetch hack and verify permissions
+ const { data: hack, error: hErr } = await supabase
+ .from("hacks")
+ .select("slug, created_by, current_patch, original_author")
+ .eq("slug", slug)
+ .maybeSingle();
+ if (hErr || !hack) return { ok: false, error: "Hack not found" };
+
+ if (!canEditAsCreator(hack, user.id)) {
+ return { ok: false, error: "Forbidden" };
+ }
+
+ // Verify patch belongs to this hack and get its created_at
+ const { data: rollbackPatch, error: pErr } = await supabase
+ .from("patches")
+ .select("id, parent_hack, created_at")
+ .eq("id", patchId)
+ .maybeSingle();
+ if (pErr || !rollbackPatch || rollbackPatch.parent_hack !== slug) {
+ return { ok: false, error: "Patch not found" };
+ }
+
+ // Update current_patch
+ const { error: updateHackErr } = await supabase
+ .from("hacks")
+ .update({ current_patch: patchId })
+ .eq("slug", slug);
+ if (updateHackErr) return { ok: false, error: updateHackErr.message };
+
+ // Unpublish all patches created after the rollback patch
+ const serviceClient = await createServiceClient();
+ const { error: unpubErr } = await serviceClient
+ .from("patches")
+ .update({ published: false })
+ .eq("parent_hack", slug)
+ .gt("created_at", rollbackPatch.created_at);
+
+ if (unpubErr) return { ok: false, error: unpubErr.message };
+
+ revalidatePath(`/hack/${slug}/versions`);
+ revalidatePath(`/hack/${slug}`);
+ return { ok: true };
+}
+
+export async function updatePatchChangelog(slug: string, patchId: number, changelog: string): Promise<{ ok: true } | { ok: false; error: string }> {
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) return { ok: false, error: "Unauthorized" };
+
+ // Fetch hack and verify permissions
+ const { data: hack, error: hErr } = await supabase
+ .from("hacks")
+ .select("slug, created_by, current_patch, original_author")
+ .eq("slug", slug)
+ .maybeSingle();
+ if (hErr || !hack) return { ok: false, error: "Hack not found" };
+
+ if (!canEditAsCreator({ created_by: hack.created_by, current_patch: hack.current_patch, original_author: hack.original_author }, user.id)) {
+ return { ok: false, error: "Forbidden" };
+ }
+
+ // Verify patch belongs to this hack
+ const { data: patch, error: pErr } = await supabase
+ .from("patches")
+ .select("id, parent_hack")
+ .eq("id", patchId)
+ .maybeSingle();
+ if (pErr || !patch || patch.parent_hack !== slug) {
+ return { ok: false, error: "Patch not found" };
+ }
+
+ // Update changelog
+ const serviceClient = await createServiceClient();
+ const { error: updateErr } = await serviceClient
+ .from("patches")
+ .update({ changelog: changelog.trim() || null })
+ .eq("id", patchId);
+
+ if (updateErr) return { ok: false, error: updateErr.message };
+
+ revalidatePath(`/hack/${slug}/versions`);
+ revalidatePath(`/hack/${slug}/changelog`);
+ return { ok: true };
+}
+
+export async function publishPatchVersion(slug: string, patchId: number): Promise<{ ok: true; willBecomeCurrent?: boolean } | { ok: false; error: string }> {
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) return { ok: false, error: "Unauthorized" };
+
+ // Fetch hack and verify permissions
+ const { data: hack, error: hErr } = await supabase
+ .from("hacks")
+ .select("slug, created_by, current_patch, original_author")
+ .eq("slug", slug)
+ .maybeSingle();
+ if (hErr || !hack) return { ok: false, error: "Hack not found" };
+
+ if (!canEditAsCreator(hack, user.id)) {
+ return { ok: false, error: "Forbidden" };
+ }
+
+ // Verify patch belongs to this hack and get its created_at
+ const { data: patch, error: pErr } = await supabase
+ .from("patches")
+ .select("id, parent_hack, created_at")
+ .eq("id", patchId)
+ .maybeSingle();
+ if (pErr || !patch || patch.parent_hack !== slug) {
+ return { ok: false, error: "Patch not found" };
+ }
+
+ // Check if this patch is newer than current_patch
+ let willBecomeCurrent = false;
+ if (hack.current_patch) {
+ const serviceClient = await createServiceClient();
+ const { data: currentPatch } = await serviceClient
+ .from("patches")
+ .select("created_at")
+ .eq("id", hack.current_patch)
+ .maybeSingle();
+ if (currentPatch && new Date(patch.created_at) > new Date(currentPatch.created_at)) {
+ willBecomeCurrent = true;
+ }
+ } else {
+ willBecomeCurrent = true;
+ }
+
+ // Publish the patch
+ const { error: updateErr } = await supabase
+ .from("patches")
+ .update({ published: true, published_at: new Date().toISOString() })
+ .eq("id", patchId);
+ if (updateErr) return { ok: false, error: updateErr.message };
+
+ // If newer than current_patch, update current_patch
+ if (willBecomeCurrent) {
+ const { error: updateHackErr } = await supabase
+ .from("hacks")
+ .update({ current_patch: patchId })
+ .eq("slug", slug);
+ if (updateHackErr) return { ok: false, error: updateHackErr.message };
+ }
+
+ revalidatePath(`/hack/${slug}/versions`);
+ revalidatePath(`/hack/${slug}`);
+ return { ok: true, willBecomeCurrent };
+}
+
+export async function reuploadPatchVersion(
+ slug: string,
+ patchId: number,
+ objectKey: string
+): Promise<{ ok: true; presignedUrl: string } | { ok: false; error: string }> {
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) return { ok: false, error: "Unauthorized" };
+
+ // Fetch hack and verify permissions
+ const { data: hack, error: hErr } = await supabase
+ .from("hacks")
+ .select("slug, created_by, current_patch, original_author")
+ .eq("slug", slug)
+ .maybeSingle();
+ if (hErr || !hack) return { ok: false, error: "Hack not found" };
+
+ if (!canEditAsCreator({ created_by: hack.created_by, current_patch: hack.current_patch, original_author: hack.original_author }, user.id)) {
+ return { ok: false, error: "Forbidden" };
+ }
+
+ // Verify patch belongs to this hack
+ const { data: patch, error: pErr } = await supabase
+ .from("patches")
+ .select("id, parent_hack, filename")
+ .eq("id", patchId)
+ .maybeSingle();
+ if (pErr || !patch || patch.parent_hack !== slug) {
+ return { ok: false, error: "Patch not found" };
+ }
+
+ // Generate presigned URL for upload
+ const client = getMinioClient();
+ const url = await client.presignedPutObject(PATCHES_BUCKET, objectKey, 60 * 10);
+
+ // Update patch filename after upload (caller should handle the actual upload and update)
+ return { ok: true, presignedUrl: url };
+}
+
+export async function confirmReuploadPatchVersion(
+ slug: string,
+ patchId: number,
+ objectKey: string
+): Promise<{ ok: true } | { ok: false; error: string }> {
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) return { ok: false, error: "Unauthorized" };
+
+ // Fetch hack and verify permissions
+ const { data: hack, error: hErr } = await supabase
+ .from("hacks")
+ .select("slug, created_by, current_patch, original_author")
+ .eq("slug", slug)
+ .maybeSingle();
+ if (hErr || !hack) return { ok: false, error: "Hack not found" };
+
+ if (!canEditAsCreator({ created_by: hack.created_by, current_patch: hack.current_patch, original_author: hack.original_author }, user.id)) {
+ return { ok: false, error: "Forbidden" };
+ }
+
+ // Verify patch belongs to this hack
+ const { data: patch, error: pErr } = await supabase
+ .from("patches")
+ .select("id, parent_hack")
+ .eq("id", patchId)
+ .maybeSingle();
+ if (pErr || !patch || patch.parent_hack !== slug) {
+ return { ok: false, error: "Patch not found" };
+ }
+
+ // Update patch filename
+ const serviceClient = await createServiceClient();
+ const { error: updateErr } = await serviceClient
+ .from("patches")
+ .update({ filename: objectKey, updated_at: new Date().toISOString() })
+ .eq("id", patchId);
+
+ if (updateErr) return { ok: false, error: updateErr.message };
+
+ revalidatePath(`/hack/${slug}/versions`);
+ return { ok: true };
+}
+
diff --git a/src/app/hack/[slug]/changelog/page.tsx b/src/app/hack/[slug]/changelog/page.tsx
new file mode 100644
index 0000000..96ccc63
--- /dev/null
+++ b/src/app/hack/[slug]/changelog/page.tsx
@@ -0,0 +1,104 @@
+import { notFound } from "next/navigation";
+import { createClient } from "@/utils/supabase/server";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import rehypeSlug from "rehype-slug";
+import Link from "next/link";
+import { FaChevronLeft } from "react-icons/fa6";
+
+interface ChangelogPageProps {
+ params: Promise<{ slug: string }>;
+}
+
+export default async function ChangelogPage({ params }: ChangelogPageProps) {
+ const { slug } = await params;
+ const supabase = await createClient();
+
+ // Fetch hack
+ const { data: hack } = await supabase
+ .from("hacks")
+ .select("slug, title, current_patch")
+ .eq("slug", slug)
+ .maybeSingle();
+
+ if (!hack) return notFound();
+
+ // Fetch all published, non-archived patches with changelogs
+ const { data: patches } = await supabase
+ .from("patches")
+ .select("id, version, created_at, changelog")
+ .eq("parent_hack", slug)
+ .eq("published", true)
+ .eq("archived", false)
+ .not("changelog", "is", null)
+ .order("created_at", { ascending: false });
+
+ const patchesWithChangelogs = (patches || []).filter(p => p.changelog && p.changelog.trim().length > 0);
+
+ return (
+
+
+
+
+ Back to hack
+
+
Changelog
+
+ {hack.title}
+
+
+
+ {patchesWithChangelogs.length === 0 ? (
+
+
No changelogs available yet.
+
+ ) : (
+
+ {patchesWithChangelogs.map((patch) => (
+
+
+
+
+ {patch.version}
+ {hack.current_patch === patch.id && (
+
+ Current
+
+ )}
+
+
+ {new Date(patch.created_at).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })}
+
+
+
+
+
+ {patch.changelog || ""}
+
+
+
+ ))}
+
+ )}
+
+ );
+}
+
diff --git a/src/app/hack/[slug]/versions/page.tsx b/src/app/hack/[slug]/versions/page.tsx
new file mode 100644
index 0000000..f186973
--- /dev/null
+++ b/src/app/hack/[slug]/versions/page.tsx
@@ -0,0 +1,100 @@
+import { notFound } from "next/navigation";
+import { createClient } from "@/utils/supabase/server";
+import { canEditAsCreator } from "@/utils/hack";
+import VersionList from "@/components/Hack/VersionList";
+import Link from "next/link";
+import { FaChevronLeft, FaPlus } from "react-icons/fa6";
+
+interface VersionsPageProps {
+ params: Promise<{ slug: string }>;
+}
+
+export default async function VersionsPage({ params }: VersionsPageProps) {
+ const { slug } = await params;
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+
+ // Fetch hack
+ const { data: hack } = await supabase
+ .from("hacks")
+ .select("slug, title, created_by, current_patch, original_author, permission_from, base_rom")
+ .eq("slug", slug)
+ .maybeSingle();
+
+ if (!hack) return notFound();
+
+ // Check if user can edit (creator only for version management)
+ const canEdit = user ? canEditAsCreator(hack, user.id) : false;
+
+ // Fetch all published, non-archived patches
+ const { data: patches } = await supabase
+ .from("patches")
+ .select("id, version, created_at, updated_at, changelog, published, archived")
+ .eq("parent_hack", slug)
+ .eq("published", true)
+ .eq("archived", false)
+ .order("created_at", { ascending: false });
+
+ // Also fetch unpublished patches if user can edit
+ let unpublishedPatches: any[] = [];
+ if (canEdit) {
+ const { data: unpub } = await supabase
+ .from("patches")
+ .select("id, version, created_at, updated_at, changelog, published, archived")
+ .eq("parent_hack", slug)
+ .eq("published", false)
+ .eq("archived", false)
+ .order("created_at", { ascending: false });
+ unpublishedPatches = unpub || [];
+ }
+
+ const allPatches = [...(patches || []), ...unpublishedPatches].sort((a, b) =>
+ new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
+ );
+
+ return (
+
+
+
+
+ Back to hack
+
+
+ {canEdit ? "Manage Versions" : "Version History"}
+
+
+ {hack.title}
+
+
+
+ View Changelog
+
+ {canEdit && (
+
+
+ Upload New Version
+
+ )}
+
+
+
+
+
+ );
+}
+
diff --git a/src/app/submit/actions.ts b/src/app/submit/actions.ts
index f9ec8e9..8ec6398 100644
--- a/src/app/submit/actions.ts
+++ b/src/app/submit/actions.ts
@@ -195,7 +195,7 @@ export async function presignPatchAndSaveCovers(args: {
return { ok: true, presignedUrl: url, objectKey } as const;
}
-export async function confirmPatchUpload(args: { slug: string; objectKey: string; version: string, firstUpload?: boolean }) {
+export async function confirmPatchUpload(args: { slug: string; objectKey: string; version: string, firstUpload?: boolean; publishAutomatically?: boolean }) {
const supabase = await createClient();
const {
data: { user },
@@ -229,18 +229,51 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string
if (existing) return { ok: false, error: "That version already exists for this hack." } as const;
// Create patch row
+ const patchInsert: any = {
+ bucket: PATCHES_BUCKET,
+ filename: args.objectKey,
+ version: args.version,
+ parent_hack: args.slug,
+ };
+
+ // Set published status based on publishAutomatically flag
+ if (args.publishAutomatically) {
+ patchInsert.published = true;
+ patchInsert.published_at = new Date().toISOString();
+ } else {
+ patchInsert.published = false;
+ }
+
const { data: patch, error: pErr } = await supabase
.from("patches")
- .insert({ bucket: PATCHES_BUCKET, filename: args.objectKey, version: args.version, parent_hack: args.slug })
- .select("id")
+ .insert(patchInsert)
+ .select("id, created_at")
.single();
if (pErr) return { ok: false, error: pErr.message } as const;
+ // Only update current_patch if publishAutomatically is true
+ if (args.publishAutomatically) {
+ // Check if this patch is newer than current_patch
+ let shouldUpdateCurrentPatch = true;
+ if (hack.current_patch) {
+ const { data: currentPatch } = await supabase
+ .from("patches")
+ .select("created_at")
+ .eq("id", hack.current_patch)
+ .maybeSingle();
+ if (currentPatch && new Date(patch.created_at) <= new Date(currentPatch.created_at)) {
+ shouldUpdateCurrentPatch = false;
+ }
+ }
+
+ if (shouldUpdateCurrentPatch) {
const { error: uErr } = await supabase
.from("hacks")
.update({ current_patch: patch.id })
.eq("slug", args.slug);
if (uErr) return { ok: false, error: uErr.message } as const;
+ }
+ }
if (process.env.DISCORD_WEBHOOK_ADMIN_URL) {
const { data: profile } = await supabase.from('profiles').select('*').eq('id', user.id).single();
@@ -269,7 +302,9 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string
]);
}
- return { ok: true, patchId: patch.id, redirectTo: `/hack/${args.slug}` } as const;
+ // Redirect to versions page if not publishing automatically, otherwise to hack page
+ const redirectTo = args.publishAutomatically ? `/hack/${args.slug}` : `/hack/${args.slug}/versions`;
+ return { ok: true, patchId: patch.id, redirectTo } as const;
}
diff --git a/src/components/Hack/HackOptionsMenu.tsx b/src/components/Hack/HackOptionsMenu.tsx
index e36ea05..d73d880 100644
--- a/src/components/Hack/HackOptionsMenu.tsx
+++ b/src/components/Hack/HackOptionsMenu.tsx
@@ -35,6 +35,21 @@ export default function HackOptionsMenu({
transition
className="absolute right-0 z-10 mt-2 w-40 origin-top-right overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] backdrop-blur-lg shadow-lg focus:outline-none transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
+
+ {!canUploadPatch && (
+
+ )}
+
>}
- {canUploadPatch && <>
+ {canUploadPatch && (
- >}
+ )}
{children && <>
{children}
diff --git a/src/components/Hack/HackPatchForm.tsx b/src/components/Hack/HackPatchForm.tsx
index 1fede10..99799d1 100644
--- a/src/components/Hack/HackPatchForm.tsx
+++ b/src/components/Hack/HackPatchForm.tsx
@@ -28,6 +28,7 @@ export default function HackPatchForm(props: HackPatchFormProps) {
const [genError, setGenError] = React.useState("");
const [submitting, setSubmitting] = React.useState(false);
const [error, setError] = React.useState("");
+ const [publishAutomatically, setPublishAutomatically] = React.useState(false);
const versionInputRef = React.useRef(null);
const patchInputRef = React.useRef(null);
@@ -148,7 +149,7 @@ export default function HackPatchForm(props: HackPatchFormProps) {
const presigned = await presignNewPatchVersion({ slug, version: version.trim() });
if (!presigned.ok) throw new Error(presigned.error || 'Failed to presign');
await fetch(presigned.presignedUrl!, { method: 'PUT', body: patchFile!, headers: { 'Content-Type': 'application/octet-stream' } });
- const finalized = await confirmPatchUpload({ slug, objectKey: presigned.objectKey!, version: version.trim() });
+ const finalized = await confirmPatchUpload({ slug, objectKey: presigned.objectKey!, version: version.trim(), publishAutomatically });
if (!finalized.ok) throw new Error(finalized.error || 'Failed to finalize');
window.location.href = finalized.redirectTo!;
} catch (e: any) {
@@ -260,7 +261,24 @@ export default function HackPatchForm(props: HackPatchFormProps) {
{!!error && {error}
}
-
+
+
+
+
+