Add auto updates and tighten reply feeds
This commit is contained in:
+144
-26
@@ -14,12 +14,15 @@ from http.server import BaseHTTPRequestHandler
|
|||||||
SOCKET_PATH = os.environ.get("HOST_UPDATER_SOCKET", "/var/run/synapsis-updater/updater.sock")
|
SOCKET_PATH = os.environ.get("HOST_UPDATER_SOCKET", "/var/run/synapsis-updater/updater.sock")
|
||||||
TOKEN = os.environ.get("HOST_UPDATER_TOKEN", "")
|
TOKEN = os.environ.get("HOST_UPDATER_TOKEN", "")
|
||||||
STATUS_FILE = os.environ.get("HOST_UPDATER_STATUS_FILE", "/opt/synapsis/updater-status.json")
|
STATUS_FILE = os.environ.get("HOST_UPDATER_STATUS_FILE", "/opt/synapsis/updater-status.json")
|
||||||
|
CONFIG_FILE = os.environ.get("HOST_UPDATER_CONFIG_FILE", "/opt/synapsis/updater-config.json")
|
||||||
LOG_FILE = os.environ.get("HOST_UPDATER_LOG_FILE", "/opt/synapsis/updater.log")
|
LOG_FILE = os.environ.get("HOST_UPDATER_LOG_FILE", "/opt/synapsis/updater.log")
|
||||||
UPDATE_SCRIPT = os.environ.get("HOST_UPDATER_SCRIPT", "/opt/synapsis/update-local.sh")
|
UPDATE_SCRIPT = os.environ.get("HOST_UPDATER_SCRIPT", "/opt/synapsis/update-local.sh")
|
||||||
INSTALL_DIR = os.environ.get("INSTALL_DIR", "/opt/synapsis")
|
INSTALL_DIR = os.environ.get("INSTALL_DIR", "/opt/synapsis")
|
||||||
|
AUTO_UPDATE_INTERVAL_MINUTES = max(5, int(os.environ.get("HOST_UPDATER_INTERVAL_MINUTES", "30")))
|
||||||
|
|
||||||
status_lock = threading.RLock()
|
status_lock = threading.RLock()
|
||||||
current_process = None
|
current_process = None
|
||||||
|
last_auto_trigger_at = 0.0
|
||||||
|
|
||||||
|
|
||||||
def now_iso():
|
def now_iso():
|
||||||
@@ -29,9 +32,48 @@ def now_iso():
|
|||||||
def ensure_parent_dirs():
|
def ensure_parent_dirs():
|
||||||
os.makedirs(os.path.dirname(SOCKET_PATH), exist_ok=True)
|
os.makedirs(os.path.dirname(SOCKET_PATH), exist_ok=True)
|
||||||
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
|
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
|
||||||
|
os.makedirs(os.path.dirname(CONFIG_FILE), exist_ok=True)
|
||||||
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
|
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def default_config():
|
||||||
|
return {
|
||||||
|
"autoUpdateEnabled": True,
|
||||||
|
"intervalMinutes": AUTO_UPDATE_INTERVAL_MINUTES,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
if not os.path.exists(CONFIG_FILE):
|
||||||
|
config = default_config()
|
||||||
|
save_config(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
with open(CONFIG_FILE, "r", encoding="utf-8") as handle:
|
||||||
|
stored = json.load(handle)
|
||||||
|
|
||||||
|
config = default_config()
|
||||||
|
config.update({
|
||||||
|
"autoUpdateEnabled": bool(stored.get("autoUpdateEnabled", True)),
|
||||||
|
"intervalMinutes": max(5, int(stored.get("intervalMinutes", AUTO_UPDATE_INTERVAL_MINUTES))),
|
||||||
|
})
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(config):
|
||||||
|
with open(CONFIG_FILE, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(config, handle, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def update_config(**changes):
|
||||||
|
config = load_config()
|
||||||
|
config.update(changes)
|
||||||
|
config["autoUpdateEnabled"] = bool(config.get("autoUpdateEnabled", True))
|
||||||
|
config["intervalMinutes"] = max(5, int(config.get("intervalMinutes", AUTO_UPDATE_INTERVAL_MINUTES)))
|
||||||
|
save_config(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
def load_status():
|
def load_status():
|
||||||
if not os.path.exists(STATUS_FILE):
|
if not os.path.exists(STATUS_FILE):
|
||||||
return {
|
return {
|
||||||
@@ -85,6 +127,12 @@ def update_status(**changes):
|
|||||||
return status
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def get_status_payload():
|
||||||
|
status = load_status()
|
||||||
|
status["config"] = load_config()
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
def is_authorized(headers):
|
def is_authorized(headers):
|
||||||
expected = f"Bearer {TOKEN}"
|
expected = f"Bearer {TOKEN}"
|
||||||
return bool(TOKEN) and headers.get("Authorization", "") == expected
|
return bool(TOKEN) and headers.get("Authorization", "") == expected
|
||||||
@@ -105,7 +153,7 @@ def watch_process(process):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def start_update_process():
|
def start_update_process(trigger="manual"):
|
||||||
global current_process
|
global current_process
|
||||||
|
|
||||||
# Give the app enough time to return the 202 response before the container restarts.
|
# Give the app enough time to return the 202 response before the container restarts.
|
||||||
@@ -125,12 +173,69 @@ def start_update_process():
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
update_status(pid=current_process.pid)
|
update_status(pid=current_process.pid, trigger=trigger)
|
||||||
|
|
||||||
thread = threading.Thread(target=watch_process, args=(current_process,), daemon=True)
|
thread = threading.Thread(target=watch_process, args=(current_process,), daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_update(trigger="manual"):
|
||||||
|
with status_lock:
|
||||||
|
if current_process and current_process.poll() is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
update_status(
|
||||||
|
status="updating",
|
||||||
|
message="Synapsis update scheduled." if trigger == "manual" else "Automatic Synapsis update scheduled.",
|
||||||
|
lastStartedAt=now_iso(),
|
||||||
|
lastFinishedAt=None,
|
||||||
|
lastExitCode=None,
|
||||||
|
lastError=None,
|
||||||
|
pid=None,
|
||||||
|
trigger=trigger,
|
||||||
|
)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=start_update_process, args=(trigger,), daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def auto_update_loop():
|
||||||
|
global last_auto_trigger_at
|
||||||
|
while True:
|
||||||
|
time.sleep(60)
|
||||||
|
try:
|
||||||
|
config = load_config()
|
||||||
|
if not config.get("autoUpdateEnabled", True):
|
||||||
|
continue
|
||||||
|
|
||||||
|
interval_seconds = max(300, int(config.get("intervalMinutes", AUTO_UPDATE_INTERVAL_MINUTES)) * 60)
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
with status_lock:
|
||||||
|
if current_process and current_process.poll() is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
last_reference = last_auto_trigger_at
|
||||||
|
status = load_status()
|
||||||
|
if not last_reference:
|
||||||
|
last_started = status.get("lastStartedAt")
|
||||||
|
if last_started:
|
||||||
|
try:
|
||||||
|
last_reference = datetime.fromisoformat(last_started).timestamp()
|
||||||
|
except Exception:
|
||||||
|
last_reference = 0.0
|
||||||
|
|
||||||
|
if last_reference and (now - last_reference) < interval_seconds:
|
||||||
|
continue
|
||||||
|
|
||||||
|
last_auto_trigger_at = now
|
||||||
|
|
||||||
|
schedule_update(trigger="auto")
|
||||||
|
except Exception as error:
|
||||||
|
update_status(lastError=f"Auto-update scheduler error: {error}")
|
||||||
|
|
||||||
|
|
||||||
class ThreadedUnixServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
|
class ThreadedUnixServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
|
||||||
daemon_threads = True
|
daemon_threads = True
|
||||||
|
|
||||||
@@ -158,7 +263,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if not is_authorized(self.headers):
|
if not is_authorized(self.headers):
|
||||||
self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Unauthorized"})
|
self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Unauthorized"})
|
||||||
return
|
return
|
||||||
self.send_json(HTTPStatus.OK, load_status())
|
self.send_json(HTTPStatus.OK, get_status_payload())
|
||||||
return
|
return
|
||||||
|
|
||||||
self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"})
|
self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"})
|
||||||
@@ -174,30 +279,16 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Unauthorized"})
|
self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Unauthorized"})
|
||||||
return
|
return
|
||||||
|
|
||||||
with status_lock:
|
if not schedule_update(trigger="manual"):
|
||||||
if current_process and current_process.poll() is None:
|
self.send_json(
|
||||||
self.send_json(
|
HTTPStatus.CONFLICT,
|
||||||
HTTPStatus.CONFLICT,
|
{
|
||||||
{
|
"ok": False,
|
||||||
"ok": False,
|
"status": "updating",
|
||||||
"status": "updating",
|
"message": "An update is already running.",
|
||||||
"message": "An update is already running.",
|
},
|
||||||
},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
update_status(
|
|
||||||
status="updating",
|
|
||||||
message="Synapsis update scheduled.",
|
|
||||||
lastStartedAt=now_iso(),
|
|
||||||
lastFinishedAt=None,
|
|
||||||
lastExitCode=None,
|
|
||||||
lastError=None,
|
|
||||||
pid=None,
|
|
||||||
)
|
)
|
||||||
|
return
|
||||||
thread = threading.Thread(target=start_update_process, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
self.send_json(
|
self.send_json(
|
||||||
HTTPStatus.ACCEPTED,
|
HTTPStatus.ACCEPTED,
|
||||||
@@ -208,6 +299,31 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def do_PATCH(self):
|
||||||
|
if self.path != "/config":
|
||||||
|
self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if not is_authorized(self.headers):
|
||||||
|
self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Unauthorized"})
|
||||||
|
return
|
||||||
|
|
||||||
|
content_length = int(self.headers.get("Content-Length", "0") or "0")
|
||||||
|
raw_body = self.rfile.read(content_length) if content_length > 0 else b"{}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw_body.decode("utf-8") or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self.send_json(HTTPStatus.BAD_REQUEST, {"error": "Invalid JSON"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if "autoUpdateEnabled" not in payload:
|
||||||
|
self.send_json(HTTPStatus.BAD_REQUEST, {"error": "autoUpdateEnabled is required"})
|
||||||
|
return
|
||||||
|
|
||||||
|
config = update_config(autoUpdateEnabled=payload.get("autoUpdateEnabled"))
|
||||||
|
self.send_json(HTTPStatus.OK, {"ok": True, "config": config})
|
||||||
|
|
||||||
|
|
||||||
def cleanup_socket(*_args):
|
def cleanup_socket(*_args):
|
||||||
try:
|
try:
|
||||||
@@ -224,11 +340,13 @@ def main():
|
|||||||
os.remove(SOCKET_PATH)
|
os.remove(SOCKET_PATH)
|
||||||
|
|
||||||
normalize_status_on_startup()
|
normalize_status_on_startup()
|
||||||
|
load_config()
|
||||||
|
|
||||||
signal.signal(signal.SIGTERM, cleanup_socket)
|
signal.signal(signal.SIGTERM, cleanup_socket)
|
||||||
signal.signal(signal.SIGINT, cleanup_socket)
|
signal.signal(signal.SIGINT, cleanup_socket)
|
||||||
|
|
||||||
update_status(available=True)
|
update_status(available=True)
|
||||||
|
threading.Thread(target=auto_update_loop, daemon=True).start()
|
||||||
|
|
||||||
with ThreadedUnixServer(SOCKET_PATH, Handler) as server:
|
with ThreadedUnixServer(SOCKET_PATH, Handler) as server:
|
||||||
os.chmod(SOCKET_PATH, 0o666)
|
os.chmod(SOCKET_PATH, 0o666)
|
||||||
|
|||||||
@@ -50,10 +50,16 @@ export default function AdminPage() {
|
|||||||
lastFinishedAt?: string | null;
|
lastFinishedAt?: string | null;
|
||||||
lastExitCode?: number | null;
|
lastExitCode?: number | null;
|
||||||
lastError?: string | null;
|
lastError?: string | null;
|
||||||
|
trigger?: 'manual' | 'auto' | null;
|
||||||
|
config?: {
|
||||||
|
autoUpdateEnabled: boolean;
|
||||||
|
intervalMinutes: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [loadingUpdateStatus, setLoadingUpdateStatus] = useState(false);
|
const [loadingUpdateStatus, setLoadingUpdateStatus] = useState(false);
|
||||||
const [triggeringUpdate, setTriggeringUpdate] = useState(false);
|
const [triggeringUpdate, setTriggeringUpdate] = useState(false);
|
||||||
|
const [savingAutoUpdate, setSavingAutoUpdate] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/admin/me')
|
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 handleFaviconUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
@@ -710,6 +748,41 @@ export default function AdminPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 style={{ display: 'grid', gap: '8px', marginTop: '16px', fontSize: '13px' }}>
|
||||||
<div>
|
<div>
|
||||||
<strong>Current build:</strong>{' '}
|
<strong>Current build:</strong>{' '}
|
||||||
@@ -741,6 +814,12 @@ export default function AdminPage() {
|
|||||||
{updateStatus.updater.lastExitCode}
|
{updateStatus.updater.lastExitCode}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{updateStatus?.updater.trigger && (
|
||||||
|
<div>
|
||||||
|
<strong>Last trigger:</strong>{' '}
|
||||||
|
{updateStatus.updater.trigger === 'auto' ? 'Automatic update' : 'Manual update'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{updateStatus?.updater.lastError && (
|
{updateStatus?.updater.lastError && (
|
||||||
<div style={{ color: 'var(--danger)' }}>
|
<div style={{ color: 'var(--danger)' }}>
|
||||||
<strong>Last error:</strong>{' '}
|
<strong>Last error:</strong>{' '}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { requireAdmin } from '@/lib/auth/admin';
|
import { requireAdmin } from '@/lib/auth/admin';
|
||||||
import { getCurrentBuildInfo, getLatestPublishedBuild, compareBuildVersions } from '@/lib/version';
|
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) {
|
function isUpdateAvailable(currentVersion: string, latestVersion: string | null | undefined) {
|
||||||
if (!latestVersion) {
|
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 [];
|
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}`,
|
id: `swarm:${domain}:${post.id}`,
|
||||||
content: post.content,
|
content: post.content,
|
||||||
createdAt: new Date(post.createdAt),
|
createdAt: new Date(post.createdAt),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db, posts, users, media, nodes } from '@/db';
|
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 {
|
export interface SwarmUserProfile {
|
||||||
handle: string;
|
handle: string;
|
||||||
@@ -116,7 +116,9 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(posts.userId, user.id),
|
eq(posts.userId, user.id),
|
||||||
eq(posts.isRemoved, false)
|
eq(posts.isRemoved, false),
|
||||||
|
isNull(posts.replyToId),
|
||||||
|
isNull(posts.swarmReplyToId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(desc(posts.createdAt))
|
.orderBy(desc(posts.createdAt))
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default function PostDetailPage() {
|
|||||||
|
|
||||||
const [post, setPost] = useState<Post | null>(null);
|
const [post, setPost] = useState<Post | null>(null);
|
||||||
const [replies, setReplies] = useState<Post[]>([]);
|
const [replies, setReplies] = useState<Post[]>([]);
|
||||||
|
const [replyingTo, setReplyingTo] = useState<Post | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ export default function PostDetailPage() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setPost(data.post);
|
setPost(data.post);
|
||||||
setReplies(data.replies || []);
|
setReplies(data.replies || []);
|
||||||
|
setReplyingTo(data.post);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load post');
|
setError(err instanceof Error ? err.message : 'Failed to load post');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -47,13 +49,13 @@ export default function PostDetailPage() {
|
|||||||
let swarmReplyTo: { postId: string; nodeDomain: string; content?: string; author?: any } | undefined;
|
let swarmReplyTo: { postId: string; nodeDomain: string; content?: string; author?: any } | undefined;
|
||||||
let localReplyToId: string | undefined = replyToId;
|
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
|
// This is a reply to a swarm post - send to the origin node
|
||||||
swarmReplyTo = {
|
swarmReplyTo = {
|
||||||
postId: post.originalPostId,
|
postId: replyingTo.originalPostId,
|
||||||
nodeDomain: post.nodeDomain,
|
nodeDomain: replyingTo.nodeDomain,
|
||||||
content: post.content,
|
content: replyingTo.content,
|
||||||
author: post.author,
|
author: replyingTo.author,
|
||||||
};
|
};
|
||||||
localReplyToId = undefined; // Can't use UUID foreign key for swarm posts
|
localReplyToId = undefined; // Can't use UUID foreign key for swarm posts
|
||||||
}
|
}
|
||||||
@@ -78,6 +80,7 @@ export default function PostDetailPage() {
|
|||||||
if (post) {
|
if (post) {
|
||||||
setPost({ ...post, repliesCount: post.repliesCount + 1 });
|
setPost({ ...post, repliesCount: post.repliesCount + 1 });
|
||||||
}
|
}
|
||||||
|
setReplyingTo(post);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -172,9 +175,10 @@ export default function PostDetailPage() {
|
|||||||
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
<Compose
|
<Compose
|
||||||
onPost={handlePost}
|
onPost={handlePost}
|
||||||
replyingTo={post}
|
replyingTo={replyingTo}
|
||||||
isReply
|
isReply
|
||||||
placeholder="Post your reply"
|
placeholder="Post your reply"
|
||||||
|
onCancelReply={() => setReplyingTo(post)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -188,10 +192,8 @@ export default function PostDetailPage() {
|
|||||||
onRepost={handleRepost}
|
onRepost={handleRepost}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
parentPostAuthorId={post.author.id}
|
parentPostAuthorId={post.author.id}
|
||||||
onComment={(p) => {
|
onComment={(replyTarget) => {
|
||||||
// In detail view, commenting on a reply should probably just focus the main composer
|
setReplyingTo(replyTarget);
|
||||||
// but we could also implement nested replies later.
|
|
||||||
// For now, let's keep it simple.
|
|
||||||
const composer = document.querySelector('.compose-input') as HTMLTextAreaElement;
|
const composer = document.querySelector('.compose-input') as HTMLTextAreaElement;
|
||||||
composer?.focus();
|
composer?.focus();
|
||||||
}}
|
}}
|
||||||
|
|||||||
+14
-1
@@ -12,6 +12,11 @@ export interface HostUpdaterStatus {
|
|||||||
lastExitCode?: number | null;
|
lastExitCode?: number | null;
|
||||||
lastError?: string | null;
|
lastError?: string | null;
|
||||||
pid?: number | null;
|
pid?: number | null;
|
||||||
|
trigger?: 'manual' | 'auto' | null;
|
||||||
|
config?: {
|
||||||
|
autoUpdateEnabled: boolean;
|
||||||
|
intervalMinutes: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUpdaterConfig() {
|
function getUpdaterConfig() {
|
||||||
@@ -25,7 +30,7 @@ function getUpdaterConfig() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestUpdater<T>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> {
|
function requestUpdater<T>(method: 'GET' | 'POST' | 'PATCH', path: string, body?: unknown): Promise<T> {
|
||||||
const { socketPath, token, enabled } = getUpdaterConfig();
|
const { socketPath, token, enabled } = getUpdaterConfig();
|
||||||
|
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
@@ -114,3 +119,11 @@ export async function getHostUpdaterStatus(): Promise<HostUpdaterStatus> {
|
|||||||
export async function triggerHostUpdate() {
|
export async function triggerHostUpdate() {
|
||||||
return requestUpdater<{ ok: boolean; status: string; message?: string }>('POST', '/update');
|
return requestUpdater<{ ok: boolean; status: string; message?: string }>('POST', '/update');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateHostUpdaterConfig(autoUpdateEnabled: boolean) {
|
||||||
|
return requestUpdater<{ ok: boolean; config: { autoUpdateEnabled: boolean; intervalMinutes: number } }>(
|
||||||
|
'PATCH',
|
||||||
|
'/config',
|
||||||
|
{ autoUpdateEnabled }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user