mirror of
https://github.com/perstarkse/minne.git
synced 2026-03-30 22:32:07 +02:00
release: 1.0.0
This commit is contained in:
@@ -608,7 +608,7 @@
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.markdown-content :not(pre) > code {
|
||||
.markdown-content :not(pre)>code {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
color: var(--color-base-content);
|
||||
padding: 0.15em 0.4em;
|
||||
@@ -662,7 +662,7 @@
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .markdown-content :not(pre) > code {
|
||||
[data-theme="dark"] .markdown-content :not(pre)>code {
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
@@ -677,6 +677,136 @@
|
||||
z-index: 9999;
|
||||
box-shadow: var(--nb-shadow);
|
||||
}
|
||||
|
||||
/* .nb-label: Uppercase, bold, tracking-wide, text-xs for section headers */
|
||||
.nb-label {
|
||||
@apply uppercase font-bold tracking-wide text-xs;
|
||||
}
|
||||
|
||||
/* .nb-data: JetBrains Mono, tabular-nums for timestamps, IDs, badges */
|
||||
.nb-data {
|
||||
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The Stamp: Button :active state pushes into page */
|
||||
.nb-btn:active {
|
||||
transform: translate(2px, 2px) !important;
|
||||
box-shadow: 2px 2px 0 0 #000 !important;
|
||||
}
|
||||
|
||||
/* Staggered Card Dealing Animation */
|
||||
@keyframes deal-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Staggered deal-in animation - STRICTLY SCOPED to main content area */
|
||||
main .nb-card,
|
||||
main .nb-panel {
|
||||
animation: deal-in 300ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1)) backwards;
|
||||
}
|
||||
|
||||
/* Exclude elements that shouldn't animate even inside main */
|
||||
main nav.nb-panel,
|
||||
main .no-animation {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Apply staggered delays only to direct children of grids/lists or top-level containers */
|
||||
main .nb-masonry>.nb-card:nth-child(1),
|
||||
main .grid>.nb-panel:nth-child(1) {
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
|
||||
main .nb-masonry>.nb-card:nth-child(2),
|
||||
main .grid>.nb-panel:nth-child(2) {
|
||||
animation-delay: 50ms;
|
||||
}
|
||||
|
||||
main .nb-masonry>.nb-card:nth-child(3),
|
||||
main .grid>.nb-panel:nth-child(3) {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
|
||||
main .nb-masonry>.nb-card:nth-child(4),
|
||||
main .grid>.nb-panel:nth-child(4) {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
|
||||
main .nb-masonry>.nb-card:nth-child(5),
|
||||
main .grid>.nb-panel:nth-child(5) {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
main .nb-masonry>.nb-card:nth-child(6),
|
||||
main .grid>.nb-panel:nth-child(6) {
|
||||
animation-delay: 250ms;
|
||||
}
|
||||
|
||||
main .nb-masonry>.nb-card:nth-child(7),
|
||||
main .grid>.nb-panel:nth-child(7) {
|
||||
animation-delay: 300ms;
|
||||
}
|
||||
|
||||
main .nb-masonry>.nb-card:nth-child(8),
|
||||
main .grid>.nb-panel:nth-child(8) {
|
||||
animation-delay: 350ms;
|
||||
}
|
||||
|
||||
main .nb-masonry>.nb-card:nth-child(n+9),
|
||||
main .grid>.nb-panel:nth-child(n+9) {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
|
||||
/* HTMX Swap Fade-Up Animation */
|
||||
@keyframes fade-up {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-up {
|
||||
animation: fade-up 200ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1)) forwards;
|
||||
}
|
||||
|
||||
/* Kinetic Input: Chat Armed State */
|
||||
#chat-input:not(:placeholder-shown)~button {
|
||||
filter: saturate(1.3) brightness(1.1);
|
||||
}
|
||||
|
||||
#chat-input:not(:placeholder-shown) {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Evidence Frame for images (Tufte treatment) */
|
||||
.nb-evidence-frame {
|
||||
@apply border-2 border-neutral m-2 bg-base-200;
|
||||
}
|
||||
|
||||
.nb-evidence-frame img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.nb-evidence-frame figcaption {
|
||||
@apply text-xs px-2 py-1 border-t-2 border-neutral;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme-aware placeholder contrast tweaks */
|
||||
@@ -691,6 +821,31 @@
|
||||
color: rgba(255, 255, 255, 0.78) !important;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* === DESIGN POLISHING: Receding Reality === */
|
||||
/* Modal opens → background scales and blurs */
|
||||
body:has(dialog[open]) #main-content-wrapper,
|
||||
body.modal-open #main-content-wrapper {
|
||||
transform: scale(0.98);
|
||||
filter: blur(2px);
|
||||
transition: transform 250ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1)),
|
||||
filter 250ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1));
|
||||
}
|
||||
|
||||
#main-content-wrapper {
|
||||
transform: scale(1);
|
||||
filter: blur(0);
|
||||
transition: transform 250ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1)),
|
||||
filter 250ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1));
|
||||
}
|
||||
|
||||
/* === DESIGN POLISHING: Scroll-Linked Navbar Shadow === */
|
||||
nav {
|
||||
--scroll-depth: 0;
|
||||
box-shadow: 4px calc(4px + var(--scroll-depth) * 4px) 0 0 #000;
|
||||
transition: box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* satoshi.css */
|
||||
@@ -714,6 +869,15 @@
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('fonts/JetBrainsMono-Regular.woff2') format('woff2'),
|
||||
url('fonts/JetBrainsMono-Variable.ttf') format('truetype');
|
||||
font-weight: 400 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Minimal override: prevent DaisyUI .menu hover bg on our nb buttons */
|
||||
@layer utilities {
|
||||
|
||||
@@ -736,4 +900,4 @@
|
||||
.toast-alert-title {
|
||||
@apply text-lg font-bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
199
html-router/assets/design-polish.js
Normal file
199
html-router/assets/design-polish.js
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Design Polishing Pass - Interactive Effects
|
||||
*
|
||||
* Includes:
|
||||
* - Scroll-Linked Navbar Shadow
|
||||
* - HTMX Swap Animation
|
||||
* - Typewriter AI Response
|
||||
* - Rubberbanding Scroll
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// === SCROLL-LINKED NAVBAR SHADOW ===
|
||||
function initScrollShadow() {
|
||||
const mainContent = document.querySelector('main');
|
||||
const navbar = document.querySelector('nav');
|
||||
if (!mainContent || !navbar) return;
|
||||
|
||||
mainContent.addEventListener('scroll', () => {
|
||||
const scrollTop = mainContent.scrollTop;
|
||||
const scrollHeight = mainContent.scrollHeight - mainContent.clientHeight;
|
||||
const scrollDepth = scrollHeight > 0 ? Math.min(scrollTop / 200, 1) : 0;
|
||||
navbar.style.setProperty('--scroll-depth', scrollDepth.toFixed(2));
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
// === HTMX SWAP ANIMATION ===
|
||||
function initHtmxSwapAnimation() {
|
||||
document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||
let target = event.detail.target;
|
||||
if (!target) return;
|
||||
|
||||
// If full body swap (hx-boost), animate only the main content
|
||||
if (target.tagName === 'BODY') {
|
||||
const main = document.querySelector('main');
|
||||
if (main) target = main;
|
||||
}
|
||||
|
||||
// Only animate if target is valid and inside/is main content or a card/panel
|
||||
// Avoid animating sidebar or navbar updates
|
||||
if (target && (target.tagName === 'MAIN' || target.closest('main'))) {
|
||||
if (!target.classList.contains('animate-fade-up')) {
|
||||
target.classList.add('animate-fade-up');
|
||||
// Remove class after animation completes to allow re-animation
|
||||
setTimeout(() => {
|
||||
target.classList.remove('animate-fade-up');
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// === TYPEWRITER AI RESPONSE ===
|
||||
// Works with SSE streaming - buffers text and reveals character by character
|
||||
window.initTypewriter = function(element, options = {}) {
|
||||
const {
|
||||
minDelay = 5,
|
||||
maxDelay = 15,
|
||||
showCursor = true
|
||||
} = options;
|
||||
|
||||
let buffer = '';
|
||||
let isTyping = false;
|
||||
let cursorElement = null;
|
||||
|
||||
if (showCursor) {
|
||||
cursorElement = document.createElement('span');
|
||||
cursorElement.className = 'typewriter-cursor';
|
||||
cursorElement.textContent = '▌';
|
||||
cursorElement.style.animation = 'blink 1s step-end infinite';
|
||||
element.appendChild(cursorElement);
|
||||
}
|
||||
|
||||
function typeNextChar() {
|
||||
if (buffer.length === 0) {
|
||||
isTyping = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isTyping = true;
|
||||
const char = buffer.charAt(0);
|
||||
buffer = buffer.slice(1);
|
||||
|
||||
// Insert before cursor
|
||||
if (cursorElement && cursorElement.parentNode) {
|
||||
const textNode = document.createTextNode(char);
|
||||
element.insertBefore(textNode, cursorElement);
|
||||
} else {
|
||||
element.textContent += char;
|
||||
}
|
||||
|
||||
const delay = minDelay + Math.random() * (maxDelay - minDelay);
|
||||
setTimeout(typeNextChar, delay);
|
||||
}
|
||||
|
||||
return {
|
||||
append: function(text) {
|
||||
buffer += text;
|
||||
if (!isTyping) {
|
||||
typeNextChar();
|
||||
}
|
||||
},
|
||||
complete: function() {
|
||||
// Flush remaining buffer immediately
|
||||
if (cursorElement && cursorElement.parentNode) {
|
||||
const textNode = document.createTextNode(buffer);
|
||||
element.insertBefore(textNode, cursorElement);
|
||||
cursorElement.remove();
|
||||
} else {
|
||||
element.textContent += buffer;
|
||||
}
|
||||
buffer = '';
|
||||
isTyping = false;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// === RUBBERBANDING SCROLL ===
|
||||
function initRubberbanding() {
|
||||
const containers = document.querySelectorAll('#chat-scroll-container, .content-scroll-container');
|
||||
|
||||
containers.forEach(container => {
|
||||
let startY = 0;
|
||||
let pulling = false;
|
||||
let pullDistance = 0;
|
||||
const maxPull = 60;
|
||||
const resistance = 0.4;
|
||||
|
||||
container.addEventListener('touchstart', (e) => {
|
||||
startY = e.touches[0].clientY;
|
||||
}, { passive: true });
|
||||
|
||||
container.addEventListener('touchmove', (e) => {
|
||||
const currentY = e.touches[0].clientY;
|
||||
const diff = currentY - startY;
|
||||
|
||||
// At top boundary, pulling down
|
||||
if (container.scrollTop <= 0 && diff > 0) {
|
||||
pulling = true;
|
||||
pullDistance = Math.min(diff * resistance, maxPull);
|
||||
container.style.transform = `translateY(${pullDistance}px)`;
|
||||
}
|
||||
// At bottom boundary, pulling up
|
||||
else if (container.scrollTop + container.clientHeight >= container.scrollHeight && diff < 0) {
|
||||
pulling = true;
|
||||
pullDistance = Math.max(diff * resistance, -maxPull);
|
||||
container.style.transform = `translateY(${pullDistance}px)`;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
container.addEventListener('touchend', () => {
|
||||
if (pulling) {
|
||||
container.style.transition = 'transform 300ms cubic-bezier(0.25, 1, 0.5, 1)';
|
||||
container.style.transform = 'translateY(0)';
|
||||
setTimeout(() => {
|
||||
container.style.transition = '';
|
||||
}, 300);
|
||||
pulling = false;
|
||||
pullDistance = 0;
|
||||
}
|
||||
}, { passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
// === INITIALIZATION ===
|
||||
function init() {
|
||||
initScrollShadow();
|
||||
initHtmxSwapAnimation();
|
||||
initRubberbanding();
|
||||
}
|
||||
|
||||
// Run on DOMContentLoaded
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
// Re-init rubberbanding after HTMX navigations
|
||||
document.body.addEventListener('htmx:afterSettle', () => {
|
||||
initRubberbanding();
|
||||
});
|
||||
|
||||
// Add typewriter cursor blink animation
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
.typewriter-cursor {
|
||||
color: var(--color-accent);
|
||||
font-weight: bold;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
})();
|
||||
BIN
html-router/assets/fonts/JetBrainsMono-Regular.woff2
Normal file
BIN
html-router/assets/fonts/JetBrainsMono-Regular.woff2
Normal file
Binary file not shown.
BIN
html-router/assets/fonts/JetBrainsMono-Variable.ttf
Normal file
BIN
html-router/assets/fonts/JetBrainsMono-Variable.ttf
Normal file
Binary file not shown.
@@ -1,144 +0,0 @@
|
||||
//==========================================================
|
||||
// head-support.js
|
||||
//
|
||||
// An extension to add head tag merging.
|
||||
//==========================================================
|
||||
(function(){
|
||||
|
||||
var api = null;
|
||||
|
||||
function log() {
|
||||
//console.log(arguments);
|
||||
}
|
||||
|
||||
function mergeHead(newContent, defaultMergeStrategy) {
|
||||
|
||||
if (newContent && newContent.indexOf('<head') > -1) {
|
||||
const htmlDoc = document.createElement("html");
|
||||
// remove svgs to avoid conflicts
|
||||
var contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
|
||||
// extract head tag
|
||||
var headTag = contentWithSvgsRemoved.match(/(<head(\s[^>]*>|>)([\s\S]*?)<\/head>)/im);
|
||||
|
||||
// if the head tag exists...
|
||||
if (headTag) {
|
||||
|
||||
var added = []
|
||||
var removed = []
|
||||
var preserved = []
|
||||
var nodesToAppend = []
|
||||
|
||||
htmlDoc.innerHTML = headTag;
|
||||
var newHeadTag = htmlDoc.querySelector("head");
|
||||
var currentHead = document.head;
|
||||
|
||||
if (newHeadTag == null) {
|
||||
return;
|
||||
} else {
|
||||
// put all new head elements into a Map, by their outerHTML
|
||||
var srcToNewHeadNodes = new Map();
|
||||
for (const newHeadChild of newHeadTag.children) {
|
||||
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// determine merge strategy
|
||||
var mergeStrategy = api.getAttributeValue(newHeadTag, "hx-head") || defaultMergeStrategy;
|
||||
|
||||
// get the current head
|
||||
for (const currentHeadElt of currentHead.children) {
|
||||
|
||||
// If the current head element is in the map
|
||||
var inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
|
||||
var isReAppended = currentHeadElt.getAttribute("hx-head") === "re-eval";
|
||||
var isPreserved = api.getAttributeValue(currentHeadElt, "hx-preserve") === "true";
|
||||
if (inNewContent || isPreserved) {
|
||||
if (isReAppended) {
|
||||
// remove the current version and let the new version replace it and re-execute
|
||||
removed.push(currentHeadElt);
|
||||
} else {
|
||||
// this element already exists and should not be re-appended, so remove it from
|
||||
// the new content map, preserving it in the DOM
|
||||
srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
|
||||
preserved.push(currentHeadElt);
|
||||
}
|
||||
} else {
|
||||
if (mergeStrategy === "append") {
|
||||
// we are appending and this existing element is not new content
|
||||
// so if and only if it is marked for re-append do we do anything
|
||||
if (isReAppended) {
|
||||
removed.push(currentHeadElt);
|
||||
nodesToAppend.push(currentHeadElt);
|
||||
}
|
||||
} else {
|
||||
// if this is a merge, we remove this content since it is not in the new head
|
||||
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: currentHeadElt}) !== false) {
|
||||
removed.push(currentHeadElt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push the tremaining new head elements in the Map into the
|
||||
// nodes to append to the head tag
|
||||
nodesToAppend.push(...srcToNewHeadNodes.values());
|
||||
log("to append: ", nodesToAppend);
|
||||
|
||||
for (const newNode of nodesToAppend) {
|
||||
log("adding: ", newNode);
|
||||
var newElt = document.createRange().createContextualFragment(newNode.outerHTML);
|
||||
log(newElt);
|
||||
if (api.triggerEvent(document.body, "htmx:addingHeadElement", {headElement: newElt}) !== false) {
|
||||
currentHead.appendChild(newElt);
|
||||
added.push(newElt);
|
||||
}
|
||||
}
|
||||
|
||||
// remove all removed elements, after we have appended the new elements to avoid
|
||||
// additional network requests for things like style sheets
|
||||
for (const removedElement of removed) {
|
||||
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: removedElement}) !== false) {
|
||||
currentHead.removeChild(removedElement);
|
||||
}
|
||||
}
|
||||
|
||||
api.triggerEvent(document.body, "htmx:afterHeadMerge", {added: added, kept: preserved, removed: removed});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
htmx.defineExtension("head-support", {
|
||||
init: function(apiRef) {
|
||||
// store a reference to the internal API.
|
||||
api = apiRef;
|
||||
|
||||
htmx.on('htmx:afterSwap', function(evt){
|
||||
let xhr = evt.detail.xhr;
|
||||
if (xhr) {
|
||||
var serverResponse = xhr.response;
|
||||
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
|
||||
mergeHead(serverResponse, evt.detail.boosted ? "merge" : "append");
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
htmx.on('htmx:historyRestore', function(evt){
|
||||
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
|
||||
if (evt.detail.cacheMiss) {
|
||||
mergeHead(evt.detail.serverResponse, "merge");
|
||||
} else {
|
||||
mergeHead(evt.detail.item.head, "merge");
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
htmx.on('htmx:historyItemCreated', function(evt){
|
||||
var historyItem = evt.detail.item;
|
||||
historyItem.head = document.head.outerHTML;
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
})()
|
||||
File diff suppressed because one or more lines are too long
@@ -41,6 +41,7 @@ pub struct ScratchpadDetailData {
|
||||
user: User,
|
||||
scratchpad: ScratchpadDetail,
|
||||
conversation_archive: Vec<Conversation>,
|
||||
is_editing_title: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -180,8 +181,8 @@ pub async fn show_scratchpad_modal(
|
||||
|
||||
let scratchpad_detail = ScratchpadDetail::from(&scratchpad);
|
||||
|
||||
// Handle edit_title query parameter if needed in future
|
||||
let _ = query.edit_title.unwrap_or(false);
|
||||
// Handle edit_title query parameter
|
||||
let is_editing_title = query.edit_title.unwrap_or(false);
|
||||
|
||||
Ok(TemplateResponse::new_template(
|
||||
"scratchpad/editor_modal.html",
|
||||
@@ -189,6 +190,7 @@ pub async fn show_scratchpad_modal(
|
||||
user,
|
||||
scratchpad: scratchpad_detail,
|
||||
conversation_archive,
|
||||
is_editing_title,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -263,6 +265,7 @@ pub async fn update_scratchpad_title(
|
||||
user,
|
||||
scratchpad: ScratchpadDetail::from(&scratchpad),
|
||||
conversation_archive,
|
||||
is_editing_title: false,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fmt, str::FromStr,
|
||||
fmt,
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
@@ -198,59 +199,60 @@ pub async fn search_result_handler(
|
||||
}
|
||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
||||
|
||||
let (search_results_for_template, final_query_param_for_template) =
|
||||
if let Some(actual_query) = params.query {
|
||||
let trimmed_query = actual_query.trim();
|
||||
if trimmed_query.is_empty() {
|
||||
(Vec::<SearchResultForTemplate>::new(), String::new())
|
||||
let (search_results_for_template, final_query_param_for_template) = if let Some(actual_query) =
|
||||
params.query
|
||||
{
|
||||
let trimmed_query = actual_query.trim();
|
||||
if trimmed_query.is_empty() {
|
||||
(Vec::<SearchResultForTemplate>::new(), String::new())
|
||||
} else {
|
||||
// Use retrieval pipeline Search strategy
|
||||
let config = RetrievalConfig::for_search(SearchTarget::Both);
|
||||
|
||||
// Checkout a reranker lease if pool is available
|
||||
let reranker_lease = match &state.reranker_pool {
|
||||
Some(pool) => Some(pool.checkout().await),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let result = retrieval_pipeline::pipeline::run_pipeline(
|
||||
&state.db,
|
||||
&state.openai_client,
|
||||
Some(&state.embedding_provider),
|
||||
trimmed_query,
|
||||
&user.id,
|
||||
config,
|
||||
reranker_lease,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let search_result = match result {
|
||||
StrategyOutput::Search(sr) => sr,
|
||||
_ => SearchResult::new(vec![], vec![]),
|
||||
};
|
||||
|
||||
let mut source_ids = HashSet::new();
|
||||
for chunk_result in &search_result.chunks {
|
||||
source_ids.insert(chunk_result.chunk.source_id.clone());
|
||||
}
|
||||
for entity_result in &search_result.entities {
|
||||
source_ids.insert(entity_result.entity.source_id.clone());
|
||||
}
|
||||
|
||||
let source_label_map = if source_ids.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
// Use retrieval pipeline Search strategy
|
||||
let config = RetrievalConfig::for_search(SearchTarget::Both);
|
||||
|
||||
// Checkout a reranker lease if pool is available
|
||||
let reranker_lease = match &state.reranker_pool {
|
||||
Some(pool) => Some(pool.checkout().await),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let result = retrieval_pipeline::pipeline::run_pipeline(
|
||||
&state.db,
|
||||
&state.openai_client,
|
||||
Some(&state.embedding_provider),
|
||||
trimmed_query,
|
||||
&user.id,
|
||||
config,
|
||||
reranker_lease,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let search_result = match result {
|
||||
StrategyOutput::Search(sr) => sr,
|
||||
_ => SearchResult::new(vec![], vec![]),
|
||||
};
|
||||
|
||||
let mut source_ids = HashSet::new();
|
||||
for chunk_result in &search_result.chunks {
|
||||
source_ids.insert(chunk_result.chunk.source_id.clone());
|
||||
}
|
||||
for entity_result in &search_result.entities {
|
||||
source_ids.insert(entity_result.entity.source_id.clone());
|
||||
}
|
||||
|
||||
let source_label_map = if source_ids.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
let record_ids: Vec<RecordId> = source_ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
if id.contains(':') {
|
||||
RecordId::from_str(id).ok()
|
||||
} else {
|
||||
Some(RecordId::from_table_key(TextContent::table_name(), id))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut response = state
|
||||
let record_ids: Vec<RecordId> = source_ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
if id.contains(':') {
|
||||
RecordId::from_str(id).ok()
|
||||
} else {
|
||||
Some(RecordId::from_table_key(TextContent::table_name(), id))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut response = state
|
||||
.db
|
||||
.client
|
||||
.query(
|
||||
@@ -260,84 +262,84 @@ pub async fn search_result_handler(
|
||||
.bind(("user_id", user.id.clone()))
|
||||
.bind(("record_ids", record_ids))
|
||||
.await?;
|
||||
let contents: Vec<SourceLabelRow> = response.take(0)?;
|
||||
let contents: Vec<SourceLabelRow> = response.take(0)?;
|
||||
|
||||
tracing::debug!(
|
||||
source_id_count = source_ids.len(),
|
||||
label_row_count = contents.len(),
|
||||
"Resolved search source labels"
|
||||
tracing::debug!(
|
||||
source_id_count = source_ids.len(),
|
||||
label_row_count = contents.len(),
|
||||
"Resolved search source labels"
|
||||
);
|
||||
|
||||
let mut labels = HashMap::new();
|
||||
for content in contents {
|
||||
let label = build_source_label(&content);
|
||||
labels.insert(content.id.clone(), label.clone());
|
||||
labels.insert(
|
||||
format!("{}:{}", TextContent::table_name(), content.id),
|
||||
label,
|
||||
);
|
||||
}
|
||||
|
||||
let mut labels = HashMap::new();
|
||||
for content in contents {
|
||||
let label = build_source_label(&content);
|
||||
labels.insert(content.id.clone(), label.clone());
|
||||
labels.insert(
|
||||
format!("{}:{}", TextContent::table_name(), content.id),
|
||||
label,
|
||||
);
|
||||
}
|
||||
labels
|
||||
};
|
||||
|
||||
labels
|
||||
};
|
||||
let mut combined_results: Vec<SearchResultForTemplate> =
|
||||
Vec::with_capacity(search_result.chunks.len() + search_result.entities.len());
|
||||
|
||||
let mut combined_results: Vec<SearchResultForTemplate> =
|
||||
Vec::with_capacity(search_result.chunks.len() + search_result.entities.len());
|
||||
|
||||
// Add chunk results
|
||||
for chunk_result in search_result.chunks {
|
||||
let source_label = source_label_map
|
||||
.get(&chunk_result.chunk.source_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| fallback_source_label(&chunk_result.chunk.source_id));
|
||||
combined_results.push(SearchResultForTemplate {
|
||||
result_type: "text_chunk".to_string(),
|
||||
// Add chunk results
|
||||
for chunk_result in search_result.chunks {
|
||||
let source_label = source_label_map
|
||||
.get(&chunk_result.chunk.source_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| fallback_source_label(&chunk_result.chunk.source_id));
|
||||
combined_results.push(SearchResultForTemplate {
|
||||
result_type: "text_chunk".to_string(),
|
||||
score: chunk_result.score,
|
||||
text_chunk: Some(TextChunkForTemplate {
|
||||
id: chunk_result.chunk.id,
|
||||
source_id: chunk_result.chunk.source_id,
|
||||
source_label,
|
||||
chunk: chunk_result.chunk.chunk,
|
||||
score: chunk_result.score,
|
||||
text_chunk: Some(TextChunkForTemplate {
|
||||
id: chunk_result.chunk.id,
|
||||
source_id: chunk_result.chunk.source_id,
|
||||
source_label,
|
||||
chunk: chunk_result.chunk.chunk,
|
||||
score: chunk_result.score,
|
||||
}),
|
||||
knowledge_entity: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Add entity results
|
||||
for entity_result in search_result.entities {
|
||||
let source_label = source_label_map
|
||||
.get(&entity_result.entity.source_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| fallback_source_label(&entity_result.entity.source_id));
|
||||
combined_results.push(SearchResultForTemplate {
|
||||
result_type: "knowledge_entity".to_string(),
|
||||
score: entity_result.score,
|
||||
text_chunk: None,
|
||||
knowledge_entity: Some(KnowledgeEntityForTemplate {
|
||||
id: entity_result.entity.id,
|
||||
name: entity_result.entity.name,
|
||||
description: entity_result.entity.description,
|
||||
entity_type: format!("{:?}", entity_result.entity.entity_type),
|
||||
source_id: entity_result.entity.source_id,
|
||||
source_label,
|
||||
score: entity_result.score,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
combined_results.sort_by(|a, b| b.score.total_cmp(&a.score));
|
||||
|
||||
// Limit results
|
||||
const TOTAL_LIMIT: usize = 10;
|
||||
combined_results.truncate(TOTAL_LIMIT);
|
||||
|
||||
(combined_results, trimmed_query.to_string())
|
||||
}),
|
||||
knowledge_entity: None,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
(Vec::<SearchResultForTemplate>::new(), String::new())
|
||||
};
|
||||
|
||||
// Add entity results
|
||||
for entity_result in search_result.entities {
|
||||
let source_label = source_label_map
|
||||
.get(&entity_result.entity.source_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| fallback_source_label(&entity_result.entity.source_id));
|
||||
combined_results.push(SearchResultForTemplate {
|
||||
result_type: "knowledge_entity".to_string(),
|
||||
score: entity_result.score,
|
||||
text_chunk: None,
|
||||
knowledge_entity: Some(KnowledgeEntityForTemplate {
|
||||
id: entity_result.entity.id,
|
||||
name: entity_result.entity.name,
|
||||
description: entity_result.entity.description,
|
||||
entity_type: format!("{:?}", entity_result.entity.entity_type),
|
||||
source_id: entity_result.entity.source_id,
|
||||
source_label,
|
||||
score: entity_result.score,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
combined_results.sort_by(|a, b| b.score.total_cmp(&a.score));
|
||||
|
||||
// Limit results
|
||||
const TOTAL_LIMIT: usize = 10;
|
||||
combined_results.truncate(TOTAL_LIMIT);
|
||||
|
||||
(combined_results, trimmed_query.to_string())
|
||||
}
|
||||
} else {
|
||||
(Vec::<SearchResultForTemplate>::new(), String::new())
|
||||
};
|
||||
|
||||
Ok(TemplateResponse::new_template(
|
||||
"search/base.html",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user