feat: journal dashboard widgets, agent polish, and edit-mode live previews
Journal dashboard widgets and quick-add; rich-text quick-add dialogs. Dashboard draft sync for live edit previews; assistant bubble + API tools. Journal UX: stress slider, mood grid, query cap fix.
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import { apiError, apiJson } from "@/lib/api-handler";
|
||||
import { resolveApiAuth } from "@/lib/api-auth";
|
||||
import { getAssistantEnabled } from "@/lib/assistant-preference";
|
||||
import { isLlmConfigured } from "@/lib/llm";
|
||||
import { encodeSseEvent } from "@/modules/agent/server/progress";
|
||||
import { runAgentChat } from "@/modules/agent/server/run";
|
||||
|
||||
const chatInput = z.object({
|
||||
stream: z.boolean().optional(),
|
||||
messages: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -18,10 +21,15 @@ const chatInput = z.object({
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = await resolveApiAuth(request);
|
||||
if (!auth) {
|
||||
if (!auth?.userId) {
|
||||
return apiError("Unauthorized", 401);
|
||||
}
|
||||
|
||||
const assistantEnabled = await getAssistantEnabled(auth.userId);
|
||||
if (!assistantEnabled) {
|
||||
return apiError("Assistant not enabled", 403);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
@@ -34,6 +42,47 @@ export async function POST(request: Request) {
|
||||
return apiError(parsed.error.issues[0]?.message ?? "Validation error", 400);
|
||||
}
|
||||
|
||||
if (parsed.data.stream) {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
const send = (event: Parameters<typeof encodeSseEvent>[0]) => {
|
||||
controller.enqueue(encoder.encode(encodeSseEvent(event)));
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await runAgentChat({
|
||||
messages: parsed.data.messages,
|
||||
request,
|
||||
onProgress: send,
|
||||
});
|
||||
|
||||
send({
|
||||
type: "done",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: result.message.content,
|
||||
},
|
||||
toolCalls: result.toolCalls,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Agent request failed";
|
||||
send({ type: "error", message });
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runAgentChat({
|
||||
messages: parsed.data.messages,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { apiJson, withApiHandler } from "@/lib/api-handler";
|
||||
import { scheduleOnCalendarForScope } from "@/modules/garden/server/actions";
|
||||
import { scheduleOnCalendarInput } from "@/modules/garden/server/schemas";
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return withApiHandler(request, async (scope, req) => {
|
||||
const body: unknown = await req.json();
|
||||
const parsed = scheduleOnCalendarInput.parse({ ...(body as object), scheduleId: id });
|
||||
await scheduleOnCalendarForScope(scope, parsed);
|
||||
return apiJson({ ok: true }, 201);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from "zod";
|
||||
import { apiJson, withApiHandler } from "@/lib/api-handler";
|
||||
import {
|
||||
deleteCareScheduleForScope,
|
||||
toggleCareScheduleForScope,
|
||||
} from "@/modules/garden/server/actions";
|
||||
import { careScheduleToggleInput } from "@/modules/garden/server/schemas";
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return withApiHandler(request, async (scope, req) => {
|
||||
const body: unknown = await req.json();
|
||||
const parsed = careScheduleToggleInput.parse({ ...(body as object), id });
|
||||
await toggleCareScheduleForScope(scope, parsed);
|
||||
return apiJson({ ok: true });
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return withApiHandler(request, async (scope) => {
|
||||
z.string().uuid().parse(id);
|
||||
await deleteCareScheduleForScope(scope, { id });
|
||||
return apiJson({ ok: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { apiJson, withApiHandler } from "@/lib/api-handler";
|
||||
import { pushOverdueToTaskListForScope } from "@/modules/garden/server/actions";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return withApiHandler(request, async (scope) => {
|
||||
const result = await pushOverdueToTaskListForScope(scope);
|
||||
return apiJson(result, 201);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
import { apiJson, withApiHandler } from "@/lib/api-handler";
|
||||
import { logCareForScope } from "@/modules/garden/server/actions";
|
||||
import { careLogInput } from "@/modules/garden/server/schemas";
|
||||
import { getCareLogsForScope } from "@/modules/garden/server/queries";
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return withApiHandler(request, async (scope, req) => {
|
||||
z.string().uuid().parse(id);
|
||||
const url = new URL(req.url);
|
||||
const limitRaw = url.searchParams.get("limit");
|
||||
const limit = limitRaw ? z.coerce.number().int().min(1).max(100).parse(limitRaw) : 20;
|
||||
const logs = await getCareLogsForScope(scope.householdId, id, limit);
|
||||
return apiJson(logs);
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return withApiHandler(request, async (scope, req) => {
|
||||
z.string().uuid().parse(id);
|
||||
const body: unknown = await req.json();
|
||||
const parsed = careLogInput.parse({ ...(body as object), plantId: id });
|
||||
const log = await logCareForScope(scope, parsed);
|
||||
return apiJson(
|
||||
{
|
||||
id: log.id,
|
||||
careType: log.careType,
|
||||
notes: log.notes,
|
||||
performedAt: log.performedAt.toISOString(),
|
||||
performedBy: log.performedBy,
|
||||
},
|
||||
201,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from "zod";
|
||||
import { apiJson, withApiHandler } from "@/lib/api-handler";
|
||||
import { upsertCareScheduleForScope } from "@/modules/garden/server/actions";
|
||||
import { careScheduleInput } from "@/modules/garden/server/schemas";
|
||||
import { getCareSchedulesForScope } from "@/modules/garden/server/queries";
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return withApiHandler(request, async (scope) => {
|
||||
z.string().uuid().parse(id);
|
||||
const schedules = await getCareSchedulesForScope(scope.householdId, id);
|
||||
return apiJson(schedules);
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return withApiHandler(request, async (scope, req) => {
|
||||
z.string().uuid().parse(id);
|
||||
const body: unknown = await req.json();
|
||||
const parsed = careScheduleInput.parse({ ...(body as object), plantId: id });
|
||||
const schedule = await upsertCareScheduleForScope(scope, parsed);
|
||||
return apiJson(
|
||||
{
|
||||
id: schedule.id,
|
||||
careType: schedule.careType,
|
||||
intervalDays: schedule.intervalDays,
|
||||
lastPerformedAt: schedule.lastPerformedAt?.toISOString() ?? null,
|
||||
nextDueAt: schedule.nextDueAt?.toISOString() ?? null,
|
||||
enabled: schedule.enabled,
|
||||
},
|
||||
201,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -4,8 +4,11 @@ import { journalEntryInput } from "@/modules/journal/server/schemas";
|
||||
import { listJournalEntriesForScope } from "@/modules/journal/server/queries";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return withApiHandler(request, async (scope) => {
|
||||
const entries = await listJournalEntriesForScope(scope);
|
||||
return withApiHandler(request, async (scope, req) => {
|
||||
const url = new URL(req.url);
|
||||
const limitParam = url.searchParams.get("limit");
|
||||
const limit = limitParam ? Math.min(Math.max(Number(limitParam) || 20, 1), 100) : undefined;
|
||||
const entries = await listJournalEntriesForScope(scope, limit ? { limit } : undefined);
|
||||
return apiJson(entries);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import { withApiHandler } from "@/lib/api-handler";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return withApiHandler(request, async () => {
|
||||
const specPath = path.join(process.cwd(), "docs", "api", "openapi.yaml");
|
||||
const spec = await readFile(specPath, "utf8");
|
||||
return new Response(spec, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/yaml; charset=utf-8" },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { apiJson, withApiHandler } from "@/lib/api-handler";
|
||||
import { revokeShareLinkForScope } from "@/modules/_core/share-api";
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return withApiHandler(request, async (scope) => {
|
||||
z.string().uuid().parse(id);
|
||||
await revokeShareLinkForScope(scope, id);
|
||||
return apiJson({ ok: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { z } from "zod";
|
||||
import { apiJson, withApiHandler } from "@/lib/api-handler";
|
||||
import {
|
||||
createShareLinkForScope,
|
||||
listShareLinksForScope,
|
||||
listShareableEntityTypes,
|
||||
} from "@/modules/_core/share-api";
|
||||
|
||||
const createShareLinkInput = z.object({
|
||||
entityType: z.string().trim().min(1),
|
||||
entityId: z.string().uuid(),
|
||||
expiresAt: z.string().datetime().nullable().optional(),
|
||||
capabilities: z
|
||||
.object({
|
||||
read: z.boolean().optional(),
|
||||
write: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return withApiHandler(request, async (scope, req) => {
|
||||
const url = new URL(req.url);
|
||||
const entityType = url.searchParams.get("entityType") ?? undefined;
|
||||
const entityId = url.searchParams.get("entityId") ?? undefined;
|
||||
|
||||
if (url.searchParams.get("entityTypes") === "true") {
|
||||
return apiJson(listShareableEntityTypes());
|
||||
}
|
||||
|
||||
const links = await listShareLinksForScope(scope, { entityType, entityId });
|
||||
return apiJson(links);
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return withApiHandler(request, async (scope, req) => {
|
||||
const body: unknown = await req.json();
|
||||
const parsed = createShareLinkInput.parse(body);
|
||||
const expiresAt = parsed.expiresAt ? new Date(parsed.expiresAt) : null;
|
||||
|
||||
const result = await createShareLinkForScope(scope, parsed.entityType, parsed.entityId, {
|
||||
expiresAt,
|
||||
capabilities: parsed.capabilities,
|
||||
});
|
||||
|
||||
return apiJson(
|
||||
{
|
||||
url: result.url,
|
||||
expiresAt: result.expiresAt?.toISOString() ?? null,
|
||||
},
|
||||
201,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { AssistantChat } from "@/modules/agent/components/assistant-chat";
|
||||
import { isLlmConfigured } from "@/lib/llm";
|
||||
|
||||
export default function AssistantPage() {
|
||||
return <AssistantChat configured={isLlmConfigured()} />;
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { notFound } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import { parseDashboardLayout, widgetContentKey } from "@/lib/dashboard";
|
||||
import { computeDefaultLayout } from "@/lib/dashboard.server";
|
||||
import { readEditorDraftLayout } from "@/lib/dashboard-editor-draft";
|
||||
import { computeDefaultLayout, normalizeDashboardLayout } from "@/lib/dashboard.server";
|
||||
import { getWidget, getWidgetMetas } from "@/modules/_core";
|
||||
import { getDashboardBySlug } from "@/app/d/actions";
|
||||
import { DashboardEditor } from "@/components/dashboard-editor";
|
||||
@@ -49,7 +50,9 @@ export default async function DashboardPage({
|
||||
const dashboard = await getDashboardBySlug(slug);
|
||||
if (!dashboard) notFound();
|
||||
|
||||
const layout = parseDashboardLayout(dashboard.layout) ?? computeDefaultLayout();
|
||||
const storedLayout = parseDashboardLayout(dashboard.layout) ?? computeDefaultLayout();
|
||||
const draftLayout = isEditing ? await readEditorDraftLayout(dashboard.id) : null;
|
||||
const layout = normalizeDashboardLayout(draftLayout ?? storedLayout);
|
||||
const widgetMetas = getWidgetMetas();
|
||||
|
||||
if (isEditing) {
|
||||
@@ -67,6 +70,7 @@ export default async function DashboardPage({
|
||||
|
||||
return (
|
||||
<DashboardEditor
|
||||
key={`${dashboard.id}:${JSON.stringify(layout.widgets)}`}
|
||||
dashboard={{ id: dashboard.id, name: dashboard.name, slug: dashboard.slug }}
|
||||
layout={layout}
|
||||
widgetMetas={widgetMetas}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getWidget } from "@/modules/_core";
|
||||
import { dashboards } from "@/modules/_core/schema";
|
||||
import { type DashboardLayout } from "@/lib/dashboard";
|
||||
import { computeDefaultLayout } from "@/lib/dashboard.server";
|
||||
import { clearEditorDraftLayout, writeEditorDraftLayout } from "@/lib/dashboard-editor-draft";
|
||||
|
||||
export type DashboardMeta = {
|
||||
id: string;
|
||||
@@ -156,9 +157,39 @@ export async function saveDashboardLayout(id: string, layout: DashboardLayout):
|
||||
.update(dashboards)
|
||||
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
|
||||
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
||||
await clearEditorDraftLayout(id);
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function syncEditorDraftLayout(id: string, layout: DashboardLayout): Promise<void> {
|
||||
const { user } = await getCurrentSession();
|
||||
const [row] = await db
|
||||
.select({ id: dashboards.id })
|
||||
.from(dashboards)
|
||||
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)))
|
||||
.limit(1);
|
||||
if (!row) throw new Error("Dashboard not found");
|
||||
|
||||
for (const placement of layout.widgets) {
|
||||
const widget = getWidget(placement.widgetId);
|
||||
if (!widget) continue;
|
||||
widget.configSchema.parse(placement.config);
|
||||
}
|
||||
|
||||
await writeEditorDraftLayout(id, layout);
|
||||
}
|
||||
|
||||
export async function discardEditorDraftLayout(id: string): Promise<void> {
|
||||
const { user } = await getCurrentSession();
|
||||
const [row] = await db
|
||||
.select({ id: dashboards.id })
|
||||
.from(dashboards)
|
||||
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)))
|
||||
.limit(1);
|
||||
if (!row) throw new Error("Dashboard not found");
|
||||
await clearEditorDraftLayout(id);
|
||||
}
|
||||
|
||||
export async function resetDashboardLayout(id: string): Promise<void> {
|
||||
const { user } = await getCurrentSession();
|
||||
const layout = computeDefaultLayout();
|
||||
@@ -166,6 +197,7 @@ export async function resetDashboardLayout(id: string): Promise<void> {
|
||||
.update(dashboards)
|
||||
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
|
||||
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
||||
await clearEditorDraftLayout(id);
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
|
||||
@@ -818,6 +818,86 @@
|
||||
bottom: 18px;
|
||||
}
|
||||
|
||||
/* ── Assistant chat bubble (opt-in per user) ─────────────────────── */
|
||||
.assistant-bubble {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 24px;
|
||||
z-index: 70;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
:where(html[data-nav="bottom"]) .assistant-bubble {
|
||||
bottom: calc(62px + max(env(safe-area-inset-bottom, 0px), 8px) + 12px);
|
||||
}
|
||||
:where(html[data-nav="fab"]) .assistant-bubble {
|
||||
bottom: calc(18px + 52px + 12px);
|
||||
}
|
||||
.assistant-bubble > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.assistant-bubble-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(360px, calc(100vw - 24px));
|
||||
height: min(480px, calc(100vh - 140px));
|
||||
border-radius: var(--r-md);
|
||||
border: 0.5px solid var(--hair);
|
||||
background: color-mix(in oklab, var(--card) 94%, transparent);
|
||||
backdrop-filter: blur(16px) saturate(160%);
|
||||
box-shadow:
|
||||
0 12px 40px rgba(31, 27, 22, 0.16),
|
||||
0 2px 8px rgba(31, 27, 22, 0.08);
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.assistant-bubble-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.assistant-bubble-close {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--ink-mute);
|
||||
border-radius: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.assistant-bubble-close:hover {
|
||||
background: var(--shade);
|
||||
color: var(--ink);
|
||||
}
|
||||
.assistant-bubble-trigger {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 999px;
|
||||
border: 0;
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 4px 14px rgba(31, 27, 22, 0.22),
|
||||
0 1px 2px rgba(31, 27, 22, 0.18);
|
||||
}
|
||||
.assistant-bubble-trigger:hover {
|
||||
background: var(--ink-2);
|
||||
}
|
||||
|
||||
/* ── Buttons (used inside .topbar etc; <Button> in shadcn comes from
|
||||
button.tsx and uses these tokens via the theme bridge) ───── */
|
||||
.btn {
|
||||
@@ -1430,6 +1510,65 @@ select {
|
||||
animation: emojiFloat 1.8s ease-out both;
|
||||
}
|
||||
|
||||
@keyframes journal-paper-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-28px) rotate(-1.2deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes journal-paper-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(32px) rotate(0.8deg);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.journal-entries-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.journal-entries-stack[data-phase="exit"] .journal-entry-row {
|
||||
animation: journal-paper-out 0.2s ease-in forwards;
|
||||
}
|
||||
|
||||
.journal-entries-stack[data-phase="enter"] .journal-entry-row {
|
||||
opacity: 0;
|
||||
animation: journal-paper-in 0.26s ease-out forwards;
|
||||
}
|
||||
|
||||
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(1) {
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(2) {
|
||||
animation-delay: 45ms;
|
||||
}
|
||||
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(3) {
|
||||
animation-delay: 90ms;
|
||||
}
|
||||
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(4) {
|
||||
animation-delay: 135ms;
|
||||
}
|
||||
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(5) {
|
||||
animation-delay: 180ms;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.journal-entries-stack[data-phase="exit"] .journal-entry-row,
|
||||
.journal-entries-stack[data-phase="enter"] .journal-entry-row {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Calendar min-height CSS variable ───────────────────────────── */
|
||||
/* Used by calendar-shell so the grid fills the viewport correctly */
|
||||
/* on both desktop (topbar only) and mobile (topbar + bottom nav). */
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { recordedDayKey } from "@/modules/journal/day-key";
|
||||
import { Suspense } from "react";
|
||||
import { JournalIndex } from "@/modules/journal/components/journal-index";
|
||||
import { listJournalEntries } from "@/modules/journal/server/queries";
|
||||
|
||||
export default async function JournalPage() {
|
||||
const entries = await listJournalEntries();
|
||||
const entryDays = [...new Set(entries.map((entry) => recordedDayKey(entry.recordedAt)))];
|
||||
return <JournalIndex entries={entries} entryDays={entryDays} />;
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<JournalIndex entries={entries} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { PwaRegister } from "@/components/pwa-register";
|
||||
import { InstallPrompt } from "@/components/install-prompt";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { AppToaster } from "@/components/app-toaster";
|
||||
import { AssistantBubble } from "@/modules/agent/components/assistant-bubble";
|
||||
import { isLlmConfigured } from "@/lib/llm";
|
||||
import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
|
||||
import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes";
|
||||
|
||||
@@ -99,6 +101,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
let navStyle: NavStyle = DEFAULT_THEME.navStyle;
|
||||
let userDashboards: DashboardMeta[] = [];
|
||||
let signedIn = false;
|
||||
let assistantEnabled = false;
|
||||
|
||||
const session = await auth();
|
||||
if (session?.user?.id) {
|
||||
@@ -110,6 +113,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
themeFontPair: users.themeFontPair,
|
||||
themeDensity: users.themeDensity,
|
||||
themeNavStyle: users.themeNavStyle,
|
||||
assistantEnabled: users.assistantEnabled,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, session.user.id))
|
||||
@@ -120,6 +124,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
fontPair = row.themeFontPair as FontPair;
|
||||
density = row.themeDensity as Density;
|
||||
navStyle = row.themeNavStyle as NavStyle;
|
||||
assistantEnabled = row.assistantEnabled;
|
||||
}
|
||||
userDashboards = await db
|
||||
.select({
|
||||
@@ -172,6 +177,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<CommandPalette />
|
||||
<InstallPrompt />
|
||||
<PwaRegister />
|
||||
{signedIn && assistantEnabled && session?.user?.id ? (
|
||||
<AssistantBubble configured={isLlmConfigured()} userId={session.user.id} />
|
||||
) : null}
|
||||
<AppToaster position="bottom-right" />
|
||||
</QuickAddProvider>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { signIn } from "@/lib/auth";
|
||||
import { createDevSession } from "@/lib/dev-login";
|
||||
import { isDevLoginEnabled } from "@/lib/dev-login-config";
|
||||
|
||||
export async function signInWithSso() {
|
||||
await signIn("authentik", { redirectTo: "/" });
|
||||
}
|
||||
|
||||
export async function devLogin() {
|
||||
if (!isDevLoginEnabled()) {
|
||||
throw new Error("Dev login is not enabled");
|
||||
}
|
||||
|
||||
const { sessionToken, expires } = await createDevSession();
|
||||
const cookieStore = await cookies();
|
||||
const base = { httpOnly: true, sameSite: "lax" as const, path: "/", expires };
|
||||
cookieStore.set("authjs.session-token", sessionToken, base);
|
||||
cookieStore.set("__Secure-authjs.session-token", sessionToken, {
|
||||
...base,
|
||||
secure: true,
|
||||
});
|
||||
redirect("/");
|
||||
}
|
||||
+6
-32
@@ -1,10 +1,7 @@
|
||||
import { signIn } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { isDevLoginEnabled } from "@/lib/dev-login-config";
|
||||
import { createDevSession } from "@/lib/dev-login";
|
||||
import { BrandMark } from "@/components/brand-mark";
|
||||
import { isDevLoginEnabled } from "@/lib/dev-login-config";
|
||||
import { devLogin, signInWithSso } from "./actions";
|
||||
|
||||
export default function LoginPage() {
|
||||
const devLoginEnabled = isDevLoginEnabled();
|
||||
@@ -23,41 +20,18 @@ export default function LoginPage() {
|
||||
</div>
|
||||
<h1 className="serif text-[28px] font-medium tracking-tight mb-2">famapp</h1>
|
||||
<p className="muted text-[13.5px] mb-6">Sign in to your household.</p>
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signIn("authentik", { redirectTo: "/" });
|
||||
}}
|
||||
>
|
||||
<form action={signInWithSso}>
|
||||
<Button type="submit" size="lg" className="w-full">
|
||||
Sign in with SSO
|
||||
</Button>
|
||||
</form>
|
||||
{devLoginEnabled && (
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
// Auth.js may resolve either "authjs.session-token" (HTTP/dev) or
|
||||
// "__Secure-authjs.session-token" (HTTPS) depending on AUTH_URL,
|
||||
// trustHost, and proxy headers. Set both so the session is found
|
||||
// regardless — this is dev-only code, correctness > elegance.
|
||||
const { sessionToken, expires } = await createDevSession();
|
||||
const cookieStore = await cookies();
|
||||
const base = { httpOnly: true, sameSite: "lax" as const, path: "/", expires };
|
||||
cookieStore.set("authjs.session-token", sessionToken, base);
|
||||
cookieStore.set("__Secure-authjs.session-token", sessionToken, {
|
||||
...base,
|
||||
secure: true,
|
||||
});
|
||||
redirect("/");
|
||||
}}
|
||||
className="mt-3"
|
||||
>
|
||||
{devLoginEnabled ? (
|
||||
<form action={devLogin} className="mt-3">
|
||||
<Button type="submit" variant="outline" size="lg" className="w-full">
|
||||
Dev login
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "@/lib/db";
|
||||
import { users } from "@/modules/_core/schema";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
|
||||
export async function setAssistantEnabled(enabled: boolean): Promise<void> {
|
||||
const { user } = await getCurrentSession();
|
||||
await db.update(users).set({ assistantEnabled: enabled }).where(eq(users.id, user.id));
|
||||
revalidatePath("/settings");
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
+48
-23
@@ -13,11 +13,23 @@ import { AvatarFallbackWithName } from "@/components/avatar-fallback";
|
||||
import { revokeShareLinkAction } from "./actions";
|
||||
import { getHouseholdApiTokenStatus } from "@/modules/_core/api-token";
|
||||
import { ApiTokenSettings } from "@/components/api-token-settings";
|
||||
import { AssistantOptIn } from "@/components/assistant-opt-in";
|
||||
import { listCalendars } from "@/modules/calendar/server/queries";
|
||||
import { listLists } from "@/modules/lists/server/queries";
|
||||
import Link from "next/link";
|
||||
import { NavIcon } from "@/components/nav-icon";
|
||||
import { Mail, Globe, History, Sun, Bell, Pencil, Lock, Plus, KeyRound } from "lucide-react";
|
||||
import {
|
||||
Mail,
|
||||
Globe,
|
||||
History,
|
||||
Sun,
|
||||
Bell,
|
||||
Pencil,
|
||||
Lock,
|
||||
Plus,
|
||||
KeyRound,
|
||||
MessageCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
const VALID_SECTIONS = new Set<SectionId>([
|
||||
"household",
|
||||
@@ -289,31 +301,44 @@ function AppearanceSection({
|
||||
themeDashLayout: string;
|
||||
themeCalView: string;
|
||||
themeNavStyle: string;
|
||||
assistantEnabled: boolean;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
<Sun className="size-4 text-[var(--ink-mute)]" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ThemePicker
|
||||
initialPalette={user.themePalette as "clay" | "indigo" | "sage" | "plum" | "ink"}
|
||||
initialMode={user.themeMode as "light" | "dark" | "system"}
|
||||
initialFontPair={
|
||||
user.themeFontPair as "serif-sans" | "newsreader" | "fraunces" | "sans-only"
|
||||
}
|
||||
initialDensity={user.themeDensity as "compact" | "regular" | "comfy"}
|
||||
initialDashLayout={user.themeDashLayout as "classic" | "split" | "glance"}
|
||||
initialCalView={user.themeCalView as "month" | "week" | "day"}
|
||||
initialNavStyle={
|
||||
user.themeNavStyle as "rail-desktop" | "compact-rail" | "top-nav" | "fab-only"
|
||||
}
|
||||
signedIn
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
<Sun className="size-4 text-[var(--ink-mute)]" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ThemePicker
|
||||
initialPalette={user.themePalette as "clay" | "indigo" | "sage" | "plum" | "ink"}
|
||||
initialMode={user.themeMode as "light" | "dark" | "system"}
|
||||
initialFontPair={
|
||||
user.themeFontPair as "serif-sans" | "newsreader" | "fraunces" | "sans-only"
|
||||
}
|
||||
initialDensity={user.themeDensity as "compact" | "regular" | "comfy"}
|
||||
initialDashLayout={user.themeDashLayout as "classic" | "split" | "glance"}
|
||||
initialCalView={user.themeCalView as "month" | "week" | "day"}
|
||||
initialNavStyle={
|
||||
user.themeNavStyle as "rail-desktop" | "compact-rail" | "top-nav" | "fab-only"
|
||||
}
|
||||
signedIn
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Assistant</CardTitle>
|
||||
<MessageCircle className="size-4 text-[var(--ink-mute)]" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AssistantOptIn enabled={user.assistantEnabled} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user