Compare commits

..

7 Commits

Author SHA1 Message Date
Henry
006e1f5344 chore: add verbose error logging to magic link invitation 2025-07-10 21:25:41 +01:00
Henry
03816b192f chore: hide hacker news button 2025-07-08 21:34:54 +01:00
Henry
f4ef1217dc feat: enhance label router with consistent output (#104) 2025-07-07 22:33:01 +01:00
Henry
1c920e3983 fix: update trpc dependencies and add openapi meta to providers endpoint (#103) 2025-07-07 22:32:49 +01:00
Henry
9daf657db2 chore: update changelog 2025-07-07 22:32:07 +01:00
Rees Morris
fdfd1f3d03 feat: add S3_FORCE_PATH_STYLE configuration for MinIO compatibility (#100)
* feat: add S3_FORCE_PATH_STYLE configuration for MinIO compatibility

* docs: add S3_FORCE_PATH_STYLE to env example
2025-07-07 21:06:51 +01:00
Henry
392be38f10 feat: improve mobile support on ios (#99)
* fix: add min width to dashboard

* fix: display theme background and set dynamic viewport height

* fix: prevent zooming on ios

* fix: enable scroll on card page
2025-07-03 09:12:06 +01:00
18 changed files with 102 additions and 22 deletions

View File

@@ -21,6 +21,7 @@ S3_REGION=
S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=
NEXT_PUBLIC_STORAGE_URL=
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
NEXT_PUBLIC_STORAGE_DOMAIN=

View File

@@ -5,7 +5,27 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased](https://github.com/kanbn/kan/compare/v0.2.2...HEAD)
## [Unreleased](https://github.com/kanbn/kan/compare/v0.2.3...HEAD)
## [0.2.3](https://github.com/kanbn/kan/compare/v0.2.2...v0.2.3) - 2025-07-07
### Added
- Mobile support with responsive navigation and layout
- Template selection when creating new boards with predefined lists and labels
- S3_FORCE_PATH_STYLE configuration for MinIO compatibility
### Changed
- Improved mobile UI styling and layout containers
- Enhanced iOS mobile support with viewport fixes and zoom prevention
- Improved card view layout for mobile devices
### Fixed
- Mobile dashboard min-width issues
- Dynamic viewport height on mobile devices
- Scroll functionality on card pages
## [0.2.2](https://github.com/kanbn/kan/compare/v0.2.1...v0.2.2) - 2025-06-28

View File

@@ -162,6 +162,7 @@ pnpm dev
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |

View File

@@ -31,9 +31,12 @@ const config = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
hostname: process.env.S3_FORCE_PATH_STYLE === "true"
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
},
{
protocol: "http",

View File

@@ -10,6 +10,7 @@ import {
import { authClient } from "@kan/auth/client";
import { useClickOutside } from "~/hooks/useClickOutside";
import { useTheme } from "~/providers/theme";
import FeedbackButton from "./FeedbackButton";
import SideNavigation from "./SideNavigation";
@@ -24,6 +25,8 @@ export default function Dashboard({
rightPanel,
hasRightPanel = false,
}: DashboardProps) {
const theme = useTheme();
const { data: session, isPending: sessionLoading } = authClient.useSession();
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
@@ -65,16 +68,20 @@ export default function Dashboard({
}
});
const isDarkMode = theme.activeTheme === "dark";
return (
<>
<style jsx global>{`
html {
height: 100vh;
overflow: hidden;
min-width: 320px;
background-color: ${!isDarkMode ? "hsl(0deg 0% 97.3%)" : "#161616"};
}
`}</style>
<div className="h-screen flex-col items-center bg-light-100 dark:bg-dark-50 md:flex md:min-w-[800px]">
<div className="m-auto flex h-12 min-h-12 w-full justify-between border-b border-light-600 px-5 py-2 align-middle dark:border-dark-400 md:h-16 md:min-h-16">
<div className="flex h-screen flex-col items-center bg-light-100 dark:bg-dark-50 md:min-w-[800px]">
<div className="flex h-12 min-h-12 w-full justify-between border-b border-light-600 px-5 py-2 align-middle dark:border-dark-400 md:h-16 md:min-h-16">
<div className="my-auto flex w-full items-center justify-between">
<button
ref={sideNavButtonRef}
@@ -127,10 +134,10 @@ export default function Dashboard({
</div>
</div>
<div className="flex h-full w-full">
<div className="flex h-full min-h-0 w-full">
<div
ref={sideNavRef}
className={`fixed left-0 z-50 h-[calc(100vh-3rem)] transform transition-transform duration-300 ease-in-out md:relative md:h-[calc(100vh-4rem)] md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
className={`fixed left-0 top-12 z-50 h-[calc(100dvh-3rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
>
<SideNavigation
user={{ email: session?.user.email, image: session?.user.image }}
@@ -138,14 +145,14 @@ export default function Dashboard({
/>
</div>
<div className="relative flex w-full overflow-hidden">
<div className="w-full overflow-hidden">{children}</div>
<div className="relative flex min-h-0 w-full overflow-hidden">
<div className="h-full w-full overflow-y-auto">{children}</div>
{/* Mobile Right Panel */}
{hasRightPanel && rightPanel && (
<div
ref={rightPanelRef}
className={`fixed right-0 top-12 z-50 h-[calc(100vh-3rem)] w-80 transform bg-light-200 transition-transform duration-300 ease-in-out dark:bg-dark-100 md:hidden ${
className={`fixed right-0 top-12 z-50 h-[calc(100dvh-3rem)] w-80 transform bg-light-200 transition-transform duration-300 ease-in-out dark:bg-dark-100 md:hidden ${
isRightPanelOpen ? "translate-x-0" : "translate-x-full"
}`}
>

View File

@@ -4,6 +4,10 @@ export const PageHead = ({ title }: { title: string }) => {
return (
<Head>
<title>{title}</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
</Head>
);
};

View File

@@ -69,6 +69,7 @@ export const env = createEnv({
S3_SECRET_ACCESS_KEY: z.string().optional(),
S3_REGION: z.string().optional(),
S3_ENDPOINT: z.string().optional(),
S3_FORCE_PATH_STYLE: z.string().optional(),
EMAIL_FROM: z.string().optional(),
},

View File

@@ -46,6 +46,7 @@ export default async function handler(
const client = new S3Client({
region: env.S3_REGION ?? "",
endpoint: env.S3_ENDPOINT ?? "",
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true",
credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID ?? "",
secretAccessKey: env.S3_SECRET_ACCESS_KEY ?? "",
@@ -58,6 +59,7 @@ export default async function handler(
new PutObjectCommand({
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
Key: filename,
ACL: "public-read",
}),
);

View File

@@ -189,7 +189,7 @@ export default function CardPage() {
/>
<div className="flex h-full flex-1 flex-row">
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto p-6 md:p-8">
<div className="h-full max-h-[calc(100dvh-3rem)] overflow-y-auto p-6 md:max-h-[calc(100dvh-4rem)] md:p-8">
<div className="mb-8 flex w-full items-center justify-between">
{!card && isLoading && (
<div className="flex space-x-2">

View File

@@ -40,7 +40,7 @@ export default function HomeView() {
</div>
</div>
<div className="relative overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400">
{/* <div className="relative overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400">
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
<Link
href="https://news.ycombinator.com/item?id=44157177"
@@ -58,7 +58,7 @@ export default function HomeView() {
</div>
</Link>
</div>
</div>
</div> */}
</div>
<p className="mt-2 text-center text-4xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl">

View File

@@ -104,7 +104,7 @@ export default function SettingsPage() {
return (
<>
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto">
<div className="h-full max-h-[calc(100vdh-3rem)] overflow-y-auto md:max-h-[calc(100vdh-4rem)]">
<PageHead title={t`Settings | ${workspace.name ?? "Workspace"}`} />
<div className="m-auto max-w-[1600px] px-5 py-6 md:px-28 md:py-12">
<div className="mb-8 flex w-full justify-between">

View File

@@ -36,6 +36,7 @@ services:
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_REGION=${S3_REGION}
- S3_ENDPOINT=${S3_ENDPOINT}
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}

View File

@@ -30,6 +30,7 @@ services:
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_REGION=${S3_REGION}
- S3_ENDPOINT=${S3_ENDPOINT}
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}

View File

@@ -8,6 +8,12 @@ import * as labelRepo from "@kan/db/repository/label.repo";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
const labelSchema = z.object({
publicId: z.string(),
name: z.string(),
colourCode: z.string().nullable(),
});
export const labelRouter = createTRPCRouter({
byPublicId: protectedProcedure
.meta({
@@ -21,7 +27,7 @@ export const labelRouter = createTRPCRouter({
},
})
.input(z.object({ labelPublicId: z.string().min(12) }))
.output(z.custom<Awaited<ReturnType<typeof labelRepo.getByPublicId>>>())
.output(labelSchema)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -52,7 +58,11 @@ export const labelRouter = createTRPCRouter({
code: "NOT_FOUND",
});
return result;
return {
publicId: result.publicId,
name: result.name,
colourCode: result.colourCode,
};
}),
create: protectedProcedure
.meta({
@@ -72,7 +82,7 @@ export const labelRouter = createTRPCRouter({
colourCode: z.string().length(7),
}),
)
.output(z.custom<Awaited<ReturnType<typeof labelRepo.create>>>())
.output(labelSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -108,7 +118,11 @@ export const labelRouter = createTRPCRouter({
code: "INTERNAL_SERVER_ERROR",
});
return result;
return {
publicId: result.publicId,
name: result.name,
colourCode: result.colourCode,
};
}),
update: protectedProcedure
.meta({
@@ -128,7 +142,7 @@ export const labelRouter = createTRPCRouter({
colourCode: z.string().length(7),
}),
)
.output(z.custom<Awaited<ReturnType<typeof labelRepo.update>>>())
.output(labelSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -153,7 +167,17 @@ export const labelRouter = createTRPCRouter({
const result = await labelRepo.update(ctx.db, input);
return result;
if (!result)
throw new TRPCError({
message: `Failed to update label`,
code: "INTERNAL_SERVER_ERROR",
});
return {
publicId: result.publicId,
name: result.name,
colourCode: result.colourCode,
};
}),
delete: protectedProcedure
.meta({

View File

@@ -83,11 +83,18 @@ export const memberRouter = createTRPCRouter({
callbackURL: `/boards?type=invite&memberPublicId=${invite.publicId}`,
});
if (error)
if (error) {
console.error("Failed to send magic link invitation:", {
email: input.email,
callbackURL: `/boards?type=invite&memberPublicId=${invite.publicId}`,
error,
});
throw new TRPCError({
message: `Failed to send magic link to user with email ${input.email}`,
message: `Failed to send magic link invitation to user with email ${input.email}.`,
code: "INTERNAL_SERVER_ERROR",
});
}
return invite;
}),

View File

@@ -181,6 +181,7 @@ export const initAuth = (db: dbClient) => {
const client = new S3Client({
region: env("S3_REGION") ?? "",
endpoint: env("S3_ENDPOINT") ?? "",
forcePathStyle: env("S3_FORCE_PATH_STYLE") === "true",
credentials: {
accessKeyId: env("S3_ACCESS_KEY_ID") ?? "",
secretAccessKey: env("S3_SECRET_ACCESS_KEY") ?? "",

View File

@@ -23,7 +23,12 @@ export const create = async (
createdBy: labelInput.createdBy,
boardId: labelInput.boardId,
})
.returning({ id: labels.id });
.returning({
id: labels.id,
publicId: labels.publicId,
name: labels.name,
colourCode: labels.colourCode,
});
if (labelInput.cardId && result) {
await db.insert(cardsToLabels).values({
@@ -91,6 +96,7 @@ export const update = async (
.where(eq(labels.publicId, labelInput.labelPublicId))
.returning({
id: labels.id,
publicId: labels.publicId,
name: labels.name,
colourCode: labels.colourCode,
});

View File

@@ -106,6 +106,7 @@
"S3_ACCESS_KEY_ID",
"S3_SECRET_ACCESS_KEY",
"S3_ENDPOINT",
"S3_FORCE_PATH_STYLE",
"PORT",
"BETTER_AUTH_SECRET",
"BETTER_AUTH_TRUSTED_ORIGINS"