Add auto updates and tighten reply feeds
This commit is contained in:
@@ -50,10 +50,16 @@ export default function AdminPage() {
|
||||
lastFinishedAt?: string | null;
|
||||
lastExitCode?: number | null;
|
||||
lastError?: string | null;
|
||||
trigger?: 'manual' | 'auto' | null;
|
||||
config?: {
|
||||
autoUpdateEnabled: boolean;
|
||||
intervalMinutes: number;
|
||||
};
|
||||
};
|
||||
} | null>(null);
|
||||
const [loadingUpdateStatus, setLoadingUpdateStatus] = useState(false);
|
||||
const [triggeringUpdate, setTriggeringUpdate] = useState(false);
|
||||
const [savingAutoUpdate, setSavingAutoUpdate] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/me')
|
||||
@@ -291,6 +297,38 @@ export default function AdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAutoUpdate = async () => {
|
||||
if (!updateStatus?.updater.available || !updateStatus.updater.config) return;
|
||||
|
||||
const nextValue = !updateStatus.updater.config.autoUpdateEnabled;
|
||||
setSavingAutoUpdate(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/update', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoUpdateEnabled: nextValue }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Failed to update auto-update setting');
|
||||
}
|
||||
|
||||
setUpdateStatus((prev) => prev ? {
|
||||
...prev,
|
||||
updater: {
|
||||
...prev.updater,
|
||||
config: data.config,
|
||||
},
|
||||
} : prev);
|
||||
showToast(nextValue ? 'Automatic updates enabled' : 'Automatic updates disabled', 'success');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Failed to update auto-update setting', 'error');
|
||||
} finally {
|
||||
setSavingAutoUpdate(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFaviconUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
@@ -710,6 +748,41 @@ export default function AdminPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{updateStatus?.updater.available && updateStatus?.updater.config && (
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--background)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: '16px',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', fontWeight: 600, marginBottom: '4px' }}>
|
||||
Automatic updates
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--foreground-secondary)' }}>
|
||||
Enabled by default. This node checks for updates every {updateStatus.updater.config.intervalMinutes} minutes and installs them automatically.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={`btn btn-sm ${updateStatus.updater.config.autoUpdateEnabled ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={handleToggleAutoUpdate}
|
||||
disabled={savingAutoUpdate}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{savingAutoUpdate
|
||||
? 'Saving...'
|
||||
: updateStatus.updater.config.autoUpdateEnabled
|
||||
? 'Disable'
|
||||
: 'Enable'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: '8px', marginTop: '16px', fontSize: '13px' }}>
|
||||
<div>
|
||||
<strong>Current build:</strong>{' '}
|
||||
@@ -741,6 +814,12 @@ export default function AdminPage() {
|
||||
{updateStatus.updater.lastExitCode}
|
||||
</div>
|
||||
)}
|
||||
{updateStatus?.updater.trigger && (
|
||||
<div>
|
||||
<strong>Last trigger:</strong>{' '}
|
||||
{updateStatus.updater.trigger === 'auto' ? 'Automatic update' : 'Manual update'}
|
||||
</div>
|
||||
)}
|
||||
{updateStatus?.updater.lastError && (
|
||||
<div style={{ color: 'var(--danger)' }}>
|
||||
<strong>Last error:</strong>{' '}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/auth/admin';
|
||||
import { getCurrentBuildInfo, getLatestPublishedBuild, compareBuildVersions } from '@/lib/version';
|
||||
import { getHostUpdaterStatus, triggerHostUpdate } from '@/lib/host-updater';
|
||||
import { getHostUpdaterStatus, triggerHostUpdate, updateHostUpdaterConfig } from '@/lib/host-updater';
|
||||
|
||||
function isUpdateAvailable(currentVersion: string, latestVersion: string | null | undefined) {
|
||||
if (!latestVersion) {
|
||||
@@ -56,3 +56,27 @@ export async function POST() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const body = await request.json();
|
||||
if (typeof body.autoUpdateEnabled !== 'boolean') {
|
||||
return NextResponse.json({ error: 'autoUpdateEnabled must be a boolean' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await updateHostUpdaterConfig(body.autoUpdateEnabled);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Admin required') {
|
||||
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
|
||||
}
|
||||
|
||||
console.error('[Admin Update] Config error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to update auto-update settings' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,7 +735,9 @@ export async function GET(request: Request) {
|
||||
);
|
||||
if (!profileData?.posts) return [];
|
||||
|
||||
return profileData.posts.map(post => ({
|
||||
return profileData.posts
|
||||
.filter((post: any) => !post.replyToId && !post.swarmReplyToId && !post.isReply)
|
||||
.map(post => ({
|
||||
id: `swarm:${domain}:${post.id}`,
|
||||
content: post.content,
|
||||
createdAt: new Date(post.createdAt),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, nodes } from '@/db';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
import { eq, desc, and, isNull } from 'drizzle-orm';
|
||||
|
||||
export interface SwarmUserProfile {
|
||||
handle: string;
|
||||
@@ -116,7 +116,9 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||
.where(
|
||||
and(
|
||||
eq(posts.userId, user.id),
|
||||
eq(posts.isRemoved, false)
|
||||
eq(posts.isRemoved, false),
|
||||
isNull(posts.replyToId),
|
||||
isNull(posts.swarmReplyToId)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(posts.createdAt))
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function PostDetailPage() {
|
||||
|
||||
const [post, setPost] = useState<Post | null>(null);
|
||||
const [replies, setReplies] = useState<Post[]>([]);
|
||||
const [replyingTo, setReplyingTo] = useState<Post | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -31,6 +32,7 @@ export default function PostDetailPage() {
|
||||
const data = await res.json();
|
||||
setPost(data.post);
|
||||
setReplies(data.replies || []);
|
||||
setReplyingTo(data.post);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load post');
|
||||
} finally {
|
||||
@@ -47,13 +49,13 @@ export default function PostDetailPage() {
|
||||
let swarmReplyTo: { postId: string; nodeDomain: string; content?: string; author?: any } | undefined;
|
||||
let localReplyToId: string | undefined = replyToId;
|
||||
|
||||
if (post?.isSwarm && post.nodeDomain && post.originalPostId) {
|
||||
if (replyingTo?.isSwarm && replyingTo.nodeDomain && replyingTo.originalPostId) {
|
||||
// This is a reply to a swarm post - send to the origin node
|
||||
swarmReplyTo = {
|
||||
postId: post.originalPostId,
|
||||
nodeDomain: post.nodeDomain,
|
||||
content: post.content,
|
||||
author: post.author,
|
||||
postId: replyingTo.originalPostId,
|
||||
nodeDomain: replyingTo.nodeDomain,
|
||||
content: replyingTo.content,
|
||||
author: replyingTo.author,
|
||||
};
|
||||
localReplyToId = undefined; // Can't use UUID foreign key for swarm posts
|
||||
}
|
||||
@@ -78,6 +80,7 @@ export default function PostDetailPage() {
|
||||
if (post) {
|
||||
setPost({ ...post, repliesCount: post.repliesCount + 1 });
|
||||
}
|
||||
setReplyingTo(post);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -172,9 +175,10 @@ export default function PostDetailPage() {
|
||||
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<Compose
|
||||
onPost={handlePost}
|
||||
replyingTo={post}
|
||||
replyingTo={replyingTo}
|
||||
isReply
|
||||
placeholder="Post your reply"
|
||||
onCancelReply={() => setReplyingTo(post)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -188,10 +192,8 @@ export default function PostDetailPage() {
|
||||
onRepost={handleRepost}
|
||||
onDelete={handleDelete}
|
||||
parentPostAuthorId={post.author.id}
|
||||
onComment={(p) => {
|
||||
// In detail view, commenting on a reply should probably just focus the main composer
|
||||
// but we could also implement nested replies later.
|
||||
// For now, let's keep it simple.
|
||||
onComment={(replyTarget) => {
|
||||
setReplyingTo(replyTarget);
|
||||
const composer = document.querySelector('.compose-input') as HTMLTextAreaElement;
|
||||
composer?.focus();
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user