release: 1.0.0

This commit is contained in:
Per Stark
2026-01-11 18:37:07 +01:00
parent db43be1606
commit 8fe4ac9fec
53 changed files with 757 additions and 630 deletions

View File

@@ -1,5 +1,7 @@
{% extends "modal_base.html" %}
{% block modal_class %}max-w-3xl{% endblock %}
{% block form_attributes %}
hx-patch="/update-image-prompt"
hx-target="#system_prompt_section"

View File

@@ -1,5 +1,7 @@
{% extends "modal_base.html" %}
{% block modal_class %}max-w-3xl{% endblock %}
{% block form_attributes %}
hx-patch="/update-ingestion-prompt"
hx-target="#system_prompt_section"

View File

@@ -1,5 +1,7 @@
{% extends "modal_base.html" %}
{% block modal_class %}max-w-3xl{% endblock %}
{% block form_attributes %}
hx-patch="/update-query-prompt"
hx-target="#system_prompt_section"

View File

@@ -2,8 +2,8 @@
{% block body %}
<body class="relative" hx-ext="head-support">
<div class="drawer lg:drawer-open">
<body class="relative">
<div id="main-content-wrapper" class="drawer lg:drawer-open">
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
<!-- Page Content -->
<div class="drawer-content flex flex-col h-screen">
@@ -14,6 +14,7 @@
{% block main %}{% endblock %}
<div class="p32 min-h-[10px]"></div>
</main>
{% block overlay %}{% endblock %}
</div>
<!-- Sidebar -->
{% if user %}

View File

@@ -2,10 +2,6 @@
{% block title %}Minne - Chat{% endblock %}
{% block head %}
<script src="/assets/htmx-ext-sse.js" defer></script>
{% endblock %}
{% block main %}
<div class="flex grow relative justify-center mt-2 sm:mt-4">
<div class="container">
@@ -17,32 +13,69 @@
</section>
<div id="chat-scroll-container" class="overflow-auto hide-scrollbar">
{% include "chat/history.html" %}
{% include "chat/new_message_form.html" %}
</div>
</div>
</div>
<script>
function doScrollChatToBottom() {
const mainScroll = document.querySelector('main');
if (mainScroll) mainScroll.scrollTop = mainScroll.scrollHeight;
const chatScroll = document.getElementById('chat-scroll-container');
if (chatScroll) chatScroll.scrollTop = chatScroll.scrollHeight;
const chatContainer = document.getElementById('chat_container');
if (chatContainer) chatContainer.scrollTop = chatContainer.scrollHeight;
window.scrollTo(0, document.body.scrollHeight);
}
function scrollChatToBottom() {
requestAnimationFrame(() => {
const mainScroll = document.querySelector('main');
if (mainScroll) mainScroll.scrollTop = mainScroll.scrollHeight;
const chatScroll = document.getElementById('chat-scroll-container');
if (chatScroll) chatScroll.scrollTop = chatScroll.scrollHeight;
const chatContainer = document.getElementById('chat_container');
if (chatContainer) chatContainer.scrollTop = chatContainer.scrollHeight;
window.scrollTo(0, document.body.scrollHeight);
});
if (!window.location.pathname.startsWith('/chat')) return;
requestAnimationFrame(doScrollChatToBottom);
}
window.scrollChatToBottom = scrollChatToBottom;
document.addEventListener('DOMContentLoaded', scrollChatToBottom);
// Delay initial scroll to avoid interfering with view transition
document.addEventListener('DOMContentLoaded', () => setTimeout(scrollChatToBottom, 350));
document.body.addEventListener('htmx:afterSwap', scrollChatToBottom);
document.body.addEventListener('htmx:afterSettle', scrollChatToBottom);
function handleChatSwap(e) {
if (!window.location.pathname.startsWith('/chat')) return;
// Full page swap: delay for view transition; partial swap: immediate
if (e.detail && e.detail.target && e.detail.target.tagName === 'BODY') {
setTimeout(scrollChatToBottom, 350);
} else {
scrollChatToBottom();
}
}
function cleanupChatListeners(e) {
if (e.detail && e.detail.target && e.detail.target.tagName === 'BODY') {
document.body.removeEventListener('htmx:afterSwap', window._chatEventHandlers.afterSwap);
document.body.removeEventListener('htmx:afterSettle', window._chatEventHandlers.afterSettle);
document.body.removeEventListener('htmx:beforeSwap', window._chatEventHandlers.beforeSwap);
delete window._chatEventHandlers;
window._chatListenersAttached = false;
}
}
window._chatEventHandlers = {
afterSwap: handleChatSwap,
afterSettle: handleChatSwap,
beforeSwap: cleanupChatListeners
};
if (!window._chatListenersAttached) {
document.body.addEventListener('htmx:afterSwap', window._chatEventHandlers.afterSwap);
document.body.addEventListener('htmx:afterSettle', window._chatEventHandlers.afterSettle);
document.body.addEventListener('htmx:beforeSwap', window._chatEventHandlers.beforeSwap);
window._chatListenersAttached = true;
}
</script>
{% endblock %}
{% block overlay %}
{% include "chat/new_message_form.html" %}
{% endblock %}

View File

@@ -1,6 +1,6 @@
<div class="fixed bottom-0 left-0 right-0 lg:left-72 z-20">
<div class="mx-auto max-w-3xl px-4 pb-3">
<div class="nb-panel p-2">
<div class="nb-panel p-2 no-animation">
<form hx-post="{% if conversation %} /chat/{{conversation.id}} {% else %} /chat {% endif %}"
hx-target="#chat_container" hx-swap="beforeend" class="relative flex gap-2 items-end" id="chat-form">
<textarea autofocus required name="content" placeholder="Type your message…" rows="3"

View File

@@ -8,15 +8,15 @@
{% if text_contents|length > 0 %}
<div class="nb-masonry w-full">
{% for text_content in text_contents %}
<article class="nb-card cursor-pointer mx-auto mb-4 w-full max-w-[92vw] space-y-3 sm:max-w-none"
<article class="nb-card cursor-pointer mx-auto mb-4 w-full space-y-3"
hx-get="/content/{{ text_content.id }}/read" hx-target="#modal" hx-swap="innerHTML">
{% if text_content.url_info %}
<figure class="-mx-4 -mt-4 border-b-2 border-neutral bg-base-200">
<figure class="nb-evidence-frame -mx-4 -mt-4 mb-3">
<img class="w-full h-auto" src="/file/{{text_content.url_info.image_id}}" alt="website screenshot" />
</figure>
{% endif %}
{% if text_content.file_info.mime_type == "image/png" or text_content.file_info.mime_type == "image/jpeg" %}
<figure class="-mx-4 -mt-4 border-b-2 border-neutral bg-base-200">
<figure class="nb-evidence-frame -mx-4 -mt-4 mb-3">
<img class="w-full h-auto" src="/file/{{text_content.file_info.id}}" alt="{{text_content.file_info.file_name}}" />
</figure>
{% endif %}
@@ -31,10 +31,10 @@
{% endif %}
</h2>
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-xs opacity-60 shrink-0">
<p class="nb-data text-xs opacity-60 shrink-0">
{{ text_content.created_at | datetimeformat(format="short", tz=user.timezone) }}
</p>
<span class="nb-badge">{{ text_content.category }}</span>
<span class="nb-badge nb-data">{{ text_content.category }}</span>
<div class="flex gap-2" hx-on:click="event.stopPropagation()">
{% if text_content.url_info %}
<a href="{{text_content.url_info.url}}" target="_blank" rel="noopener noreferrer"

View File

@@ -2,10 +2,6 @@
{% block title %}Minne - Dashboard{% endblock %}
{% block head %}
<script src="/assets/htmx-ext-sse.js" defer></script>
{% endblock %}
{% block main %}
<div class="flex justify-center grow mt-2 sm:mt-4 pb-4 w-full">
<div class="container">

View File

@@ -16,11 +16,15 @@
<!-- Scripts -->
<script src="/assets/htmx.min.js" defer></script>
<script src="/assets/htmx-ext-sse.js" defer></script>
<script src="/assets/theme-toggle.js" defer></script>
<script src="/assets/toast.js" defer></script>
<script src="/assets/htmx-head-ext.js" defer></script>
<script src="/assets/marked.min.js" defer></script>
<script src="/assets/knowledge-graph.js" defer></script>
<script src="/assets/design-polish.js" defer></script>
<!-- Global View Transition -->
<meta name="view-transition" content="same-origin" />
<!-- Icons -->
<link rel="icon" href="/assets/icon/favicon.ico">
@@ -38,6 +42,7 @@
(function wait_for_htmx() {
if (window.htmx) {
htmx.config.globalViewTransitions = true;
htmx.config.selfRequestsOnly = false;
} else {
setTimeout(wait_for_htmx, 50);
}

View File

@@ -1,41 +1,78 @@
{% extends "modal_base.html" %}
{% block modal_class %}max-w-3xl{% endblock %}
{% block form_attributes %}
hx-post="/ingress-form"
enctype="multipart/form-data"
{% endblock %}
{% block modal_content %}
<h3 class="text-xl font-extrabold tracking-tight">Add New Content</h3>
<div class="flex flex-col gap-3">
<h3 class="text-xl font-extrabold tracking-tight pr-8">Add New Content</h3>
<div class="flex flex-col">
<!-- Content Source -->
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Content</div>
<textarea name="content" class="nb-input w-full min-h-28"
<div class="nb-label mb-1">Content</div>
<textarea name="content" class="nb-input w-full" rows="4" autofocus
placeholder="Paste a URL or type/paste text to ingest…">{{ content }}</textarea>
</label>
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Context</div>
<textarea name="context" class="nb-input w-full min-h-24"
placeholder="Optional: add context to guide how the content should be interpreted…">{{ context }}</textarea>
<!-- Context (Optional) -->
<label class="w-full mt-6">
<div class="nb-label mb-1 flex justify-between items-center">
<span>Context</span>
<!-- Tufte-style annotation: clean, small caps, structural -->
<span class="text-[10px] tracking-widest uppercase border border-neutral px-1.5 py-px bg-transparent opacity-60">Optional</span>
</div>
<textarea name="context" class="nb-input w-full" rows="2"
placeholder="Guide how this content should be interpreted…">{{ context }}</textarea>
</label>
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Category</div>
<input type="text" name="category" class="nb-input validator w-full" value="{{ category }}" list="category-list" required />
<datalist id="category-list">
{% for category in user_categories %}
<option value="{{ category }}" />
{% endfor %}
</datalist>
<div class="validator-hint hidden text-xs opacity-70 mt-1">Category is required</div>
</label>
<!-- Metadata Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-12 gap-y-8 items-start mt-6">
<!-- Category -->
<label class="w-full">
<div class="nb-label mb-1">Category <span class="text-error font-bold" title="Required">*</span></div>
<div class="relative">
<input type="text" name="category" class="nb-input validator w-full pr-8" value="{{ category }}" list="category-list" required placeholder="Select or type..." />
<div class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none opacity-50">
{% include "icons/chevron_icon.html" %}
</div>
</div>
<datalist id="category-list">
{% for category in user_categories %}
<option value="{{ category }}" />
{% endfor %}
</datalist>
<div class="validator-hint hidden text-xs opacity-70 mt-1 text-error">Category is required</div>
</label>
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Files</div>
<input type="file" name="files" multiple class="file-input w-full rounded-none border-2 border-neutral" />
</label>
<!-- Dimensional File Drop Zone -->
<div class="w-full">
<div class="nb-label mb-1">Files</div>
<!-- "Card" style dropzone: solid border, hard shadow, lift on hover -->
<div class="relative w-full h-32 group bg-base-100 border-2 border-neutral shadow-[4px_4px_0_0_#000] hover:translate-x-[-1px] hover:translate-y-[-1px] hover:shadow-[6px_6px_0_0_#000] transition-all duration-150">
<!-- Visual Facade -->
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 text-sm font-medium text-neutral pointer-events-none">
<div class="p-2 border-2 border-neutral rounded-none bg-base-200 group-hover:bg-base-100 transition-colors">
<span class="w-6 h-6 block">{% include "icons/document_icon.html" %}</span>
</div>
<span id="file-label-text" class="text-center px-4 text-xs uppercase tracking-wide">Drop files or click</span>
</div>
<!-- Actual Input -->
<input type="file" name="files" multiple
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onchange="const count = this.files.length; document.getElementById('file-label-text').innerText = count > 0 ? count + ' FILE' + (count !== 1 ? 'S' : '') + ' SELECTED' : 'DROP FILES OR CLICK';" />
</div>
</div>
</div>
</div>
<div id="error-message" class="text-error text-center {% if not error %}hidden{% endif %}">{{ error }}</div>
<script>
(function () {
const form = document.getElementById('modal_form');
@@ -51,8 +88,9 @@ enctype="multipart/form-data"
})();
</script>
{% endblock %}
{% block primary_actions %}
<button type="submit" class="nb-btn nb-cta">
<button type="submit" class="nb-btn nb-cta w-full sm:w-auto">
Add Content
</button>
{% endblock %}

View File

@@ -1,15 +1,24 @@
<dialog id="body_modal" class="modal">
<div
class="modal-box rounded-none border-2 border-neutral bg-base-100 shadow-[8px_8px_0_0_#000] {% block modal_class %}{% endblock %}">
class="modal-box relative rounded-none border-2 border-neutral bg-base-100 shadow-[8px_8px_0_0_#000] p-6 {% block modal_class %}max-w-lg{% endblock %}">
<!-- God Level UX: Explicit Escape Hatch -->
<button type="button"
class="btn btn-sm btn-square btn-ghost absolute right-2 top-2 z-10"
onclick="document.getElementById('body_modal').close()"
aria-label="Close modal">
{% include "icons/x_icon.html" %}
</button>
<form id="modal_form" {% block form_attributes %}{% endblock %}>
<div class="flex flex-col flex-1 gap-4">
<div class="flex flex-col flex-1 gap-5">
{% block modal_content %}{% endblock %}
</div>
<div class="u-hairline mt-4 pt-3 flex flex-col gap-2 sm:flex-row sm:justify-end sm:items-center">
<!-- Close button (always visible) -->
<button type="button" class="nb-btn w-full sm:w-auto" onclick="document.getElementById('body_modal').close()">
Close
<div class="mt-8 pt-2 flex flex-col gap-2 sm:flex-row sm:justify-end sm:items-center">
<!-- Secondary Action: Ghost style to reduce noise -->
<button type="button" class="btn btn-ghost rounded-none w-full sm:w-auto hover:bg-neutral/10" onclick="document.getElementById('body_modal').close()">
Cancel
</button>
<!-- Primary actions block -->

View File

@@ -1,4 +1,4 @@
<nav class="sticky top-0 z-10 nb-panel nb-panel-canvas border-t-0">
<nav class="sticky top-0 z-10 nb-panel nb-panel-canvas border-t-0" style="view-transition-name: navbar; contain: layout;">
<div class="container mx-auto navbar">
<div class="mr-2 flex-1">
{% include "searchbar.html" %}

View File

@@ -6,7 +6,7 @@
{% block modal_content %}
<h3 class="text-xl font-extrabold tracking-tight">
<div class="flex items-center gap-2" id="title-container">
<div class="flex items-center gap-2 {% if is_editing_title %}hidden{% endif %}" id="title-container">
<span class="font-semibold text-lg flex-1 truncate" id="title-display">{{ scratchpad.title }}</span>
<button type="button" onclick="editTitle()" class="nb-btn nb-btn-sm btn-ghost">
{% include "icons/edit_icon.html" %} Edit title
@@ -15,9 +15,9 @@
<!-- Hidden title form -->
<form id="title-form" hx-patch="/scratchpad/{{ scratchpad.id }}/title" hx-target="#body_modal" hx-swap="outerHTML"
class="hidden flex items-center gap-2">
class="{% if not is_editing_title %}hidden{% endif %} flex items-center gap-2">
<input type="text" name="title" value="{{ scratchpad.title }}"
class="nb-input nb-input-sm font-semibold text-lg flex-1" id="title-input">
class="nb-input nb-input-sm font-semibold text-lg flex-1" id="title-input" {% if is_editing_title %}autofocus{% endif %}>
<button type="submit" class="nb-btn nb-btn-sm">{% include "icons/check_icon.html" %}</button>
<button type="button" onclick="cancelEditTitle()" class="nb-btn nb-btn-sm btn-ghost">{% include "icons/x_icon.html" %}</button>
</form>

View File

@@ -1,41 +0,0 @@
<!-- Theme switch script -->
<script>
const initializeTheme = () => {
console.log("Initializing theme toggle...");
const themeToggle = document.querySelector('.theme-controller');
if (!themeToggle) {
console.log("Theme toggle not found.");
return;
}
// Detect system preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// Initialize theme from local storage or system preference
const savedTheme = localStorage.getItem('theme');
const initialTheme = savedTheme ? savedTheme : (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', initialTheme);
themeToggle.checked = initialTheme === 'dark';
// Update theme and local storage on toggle
themeToggle.addEventListener('change', () => {
const theme = themeToggle.checked ? 'dark' : 'light';
console.log("Theme switched to:", theme);
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
});
console.log("Theme toggle initialized.");
};
// Run the initialization after the DOM is fully loaded
document.addEventListener('DOMContentLoaded', () => {
console.log("DOM fully loaded. Initializing theme toggle...");
initializeTheme();
});
// Reinitialize theme toggle after HTMX swaps
document.addEventListener('htmx:afterSwap', initializeTheme);
document.addEventListener('htmx:afterSettle', initializeTheme);
</script>

View File

@@ -14,7 +14,7 @@
{% endif %}
{% endmacro %}
<div class="drawer-side z-20">
<div class="drawer-side z-20" style="view-transition-name: sidebar; contain: layout;">
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<ul class="menu p-0 w-72 h-full nb-canvas text-base-content flex flex-col border-r-2 border-neutral">
@@ -47,7 +47,7 @@
</div>
<!-- === MIDDLE SCROLLABLE SECTION === -->
<span class="px-4 py-2 font-semibold tracking-wide">Recent Chats</span>
<span class="px-4 py-2 nb-label">Recent Chats</span>
<div class="flex-1 overflow-y-auto space-y-1 custom-scrollbar">
{% if conversation_archive is defined and conversation_archive %}
{% for conversation in conversation_archive %}