{"id":39572,"date":"2025-10-30T18:30:58","date_gmt":"2025-10-30T06:41:49","guid":{"rendered":"https:\/\/gruble.net\/?page_id=39572"},"modified":"2026-01-28T17:26:02","modified_gmt":"2026-01-28T16:26:02","slug":"grublelarer","status":"publish","type":"page","link":"https:\/\/gruble.net\/grublelarer\/","title":{"rendered":"Grublel\u00e6rer"},"content":{"rendered":"\t\t<div data-elementor-type=\"wp-page\" data-elementor-id=\"39572\" class=\"elementor elementor-39572\" data-elementor-post-type=\"page\">\n\t\t\t\t\t\t<section class=\"elementor-section elementor-top-section elementor-element elementor-element-4f36d41 elementor-section-boxed elementor-section-height-default elementor-section-height-default\" data-id=\"4f36d41\" data-element_type=\"section\" data-e-type=\"section\">\n\t\t\t\t\t\t<div class=\"elementor-container elementor-column-gap-default\">\n\t\t\t\t\t<div class=\"elementor-column elementor-col-100 elementor-top-column elementor-element elementor-element-cbdfdfb\" data-id=\"cbdfdfb\" data-element_type=\"column\" data-e-type=\"column\">\n\t\t\t<div class=\"elementor-widget-wrap elementor-element-populated\">\n\t\t\t\t\t\t<div class=\"elementor-element elementor-element-56fadaa elementor-widget elementor-widget-html\" data-id=\"56fadaa\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"html.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t<style>\n\/* ---------- HOVEDBOKS ---------- *\/\n#chatbox {\n  max-width: 900px;\n  margin: 0 auto;\n  background: #f2f2f2;\n  padding: 16px;\n  border-radius: 10px;\n  border: 1px solid #ccc;\n  font-family: system-ui, sans-serif;\n}\n\n\/* ---------- OVERSKRIFT ---------- *\/\n#chatbox h3 {\n  color: #111;\n  margin-top: 0;\n  text-align: center;\n}\n\n\/* ---------- SAMTALEOMR\u00c5DE ---------- *\/\n#conversation {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n  margin-top: 10px;\n  max-height: 70vh;\n  overflow-y: auto;\n}\n\n\/* ---------- MELDINGER ---------- *\/\n.msg {\n  max-width: 80%;\n  padding: 10px 14px;\n  border-radius: 18px;\n  line-height: 1.4;\n  word-wrap: break-word;\n  white-space: pre-wrap;\n  font-size: 15px;\n  position: relative;\n  overflow: visible;\n}\n\n.user {\n  align-self: flex-end;\n  background: #2d6cdf;\n  color: #fff;\n  border-bottom-right-radius: 4px;\n}\n\n.ai {\n  align-self: flex-start;\n  background: #fff;\n  color: #111;\n  border: 1px solid #ddd;\n  border-bottom-left-radius: 4px;\n}\n\n\/* ---------- LYDKONTROLLER ---------- *\/\n.controls {\n  position: relative;\n  display: inline-flex;\n  gap: 8px;\n  font-size: 18px;\n  cursor: pointer;\n  color: #2d6cdf;\n  background: #fff;\n  border: 1px solid #ddd;\n  border-radius: 16px;\n  padding: 4px 8px;\n  box-shadow: 0 2px 4px rgba(0,0,0,0.1);\n  margin-top: 6px;\n  margin-left: 6px;\n  align-self: flex-start;\n}\n.controls span:hover { color: #1b4fb8; }\n\n\/* ---------- INPUTRAD (MIC, FELT, KNAPPER) ---------- *\/\n#inputrow {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  margin-top: 12px;\n}\n\n\/* ---------- TEKSTFELT ---------- *\/\ntextarea#prompt {\n  flex: 1;\n  width: 100%;\n  background: #fff;\n  color: #111;\n  border: 1px solid #888;\n  border-radius: 20px;\n  padding: 10px 14px;\n  font-size: 15px;\n  line-height: 1.4;\n  font-family: system-ui, sans-serif;\n  resize: none;\n  overflow-y: auto;\n  min-height: 38px;\n  max-height: 200px;\n  box-sizing: border-box;\n  outline: none;\n  transition: border-color 0.2s ease;\n}\ntextarea#prompt:focus { border-color: #2d6cdf; }\n\n\/* ---------- RUNDE KNAPPER (SEND \/ HARDSTOP) ---------- *\/\n#sendBtn, #hardStopBtn {\n  width: 38px; height: 38px;\n  border-radius: 50%;\n  border: none;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  font-size: 18px;\n  color: #fff;\n  cursor: pointer;\n  transition: transform 0.2s, background 0.2s;\n  flex: 0 0 auto;\n  padding: 0;\n  line-height: 1;\n}\n#sendBtn { background: #2d6cdf; }\n#sendBtn:hover { background: #1b4fb8; transform: scale(1.1); }\n#hardStopBtn { background: #d9534f; }\n#hardStopBtn:hover { background: #b52b27; transform: scale(1.1); }\n\n\/* ---------- ANIMASJON: \"SKRIVER...\" ---------- *\/\n.typing-indicator {\n  display: inline-flex;\n  align-items: center;\n  justify-content: flex-start;\n  gap: 5px;\n  height: 20px;\n  margin: 4px 0;\n  padding-left: 8px;\n}\n.typing-indicator span {\n  width: 6px;\n  height: 6px;\n  background-color: #2d6cdf;\n  border-radius: 50%;\n  opacity: 0.6;\n  animation: typingBounce 1.4s infinite ease-in-out both;\n}\n.typing-indicator span:nth-child(1) { animation-delay: 0s; }\n.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }\n.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }\n@keyframes typingBounce {\n  0%,80%,100%{transform:scale(0.6);opacity:0.4;}\n  40%{transform:scale(1);opacity:1;}\n}\n\n\/* ---------- LENKER I SVAR ---------- *\/\n#conversation a {\n  color: #2d6cdf !important;\n  text-decoration: none !important;\n  font-weight: 600 !important;\n  border-bottom: 1px solid rgba(45,108,223,0.3);\n  transition: color 0.2s ease, border-color 0.2s ease;\n}\n#conversation a:hover {\n  color: #1b4fb8 !important;\n  border-bottom: 1px solid rgba(27,79,184,0.4);\n  text-decoration: underline !important;\n}\n\n\/* \ud83c\udf99\ufe0f --- NY MIKROFON MED RING OG PRIKK --- *\/\n#micWrapper {\n  position: relative;\n  width: 42px;\n  height: 42px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  cursor: pointer;\n  flex-shrink: 0;\n}\n\n\/* \ud83c\udf99\ufe0f Ikonet *\/\n#micIcon {\n  position: absolute;\n  top: 50%; left: 50%;\n  transform: translate(-50%, -50%);\n  font-size: 20px;\n  color: #555;\n  z-index: 3;\n  transition: opacity 0.3s ease;\n}\n#micWrapper.recording #micIcon { opacity: 0; }\n\n\/* Fremdriftsringen (SVG) *\/\n#recordRing {\n  position: absolute;\n  top: 0; left: 0;\n  width: 42px; height: 42px;\n  transform: rotate(-90deg);\n  z-index: 1;\n  pointer-events: none;\n}\n#recordRing circle {\n  fill: none;\n  stroke-width: 3.5;\n  transform-origin: 50% 50%;\n}\n#recordRing circle.bg { stroke: #eee; }\n#recordRing circle.progress {\n  stroke: #e12222;\n  stroke-linecap: round;\n  stroke-dasharray: 119.38;\n  stroke-dashoffset: 119.38;\n  transition: stroke-dashoffset 20s linear, stroke 5s linear;\n}\n\n\/* Pulserende prikk i midten *\/\n#recordDot {\n  position: absolute;\n  top: 50%; left: 50%;\n  transform: translate(-50%, -50%);\n  width: 10px; height: 10px;\n  background: #e12222;\n  border-radius: 50%;\n  opacity: 0;\n  z-index: 2;\n  transition: opacity 0.3s ease;\n}\n#micWrapper.recording #recordDot {\n  opacity: 1;\n  animation: dotPulse 1s infinite ease-in-out;\n}\n@keyframes dotPulse {\n  0%,100%{transform:translate(-50%,-50%) scale(1);opacity:.8;}\n  50%{transform:translate(-50%,-50%) scale(1.4);opacity:1;}\n}\n\n\/* Hover-effekt n\u00e5r inaktiv *\/\n#micWrapper:hover #recordRing circle.bg { stroke: #ccc; }\n\n\/* Fargeendring i ringen (r\u00f8d \u2192 oransje \u2192 gul) *\/\n@keyframes ringFade {\n  0% { stroke: #e12222; }\n  75% { stroke: #ff8c00; }\n  100% { stroke: #ffcc00; }\n}\n#micWrapper.recording #recordRing circle.progress {\n  animation: ringFade 20s linear forwards;\n}\n\n\/* --- Karakterteller under tekstfeltet --- *\/\n#charCount {\n  font-size: 13px;\n  text-align: right;\n  color: #666;\n  margin-top: 4px;\n  margin-right: 6px;\n  user-select: none;\n  font-family: system-ui, sans-serif;\n  transition: color 0.3s ease;\n}\n\n<\/style>\n\n<div id=\"chatbox\">\n  <h3>\ud83d\udcac Grublel\u00e6reren<\/h3>\n  <div id=\"conversation\"><\/div>\n\n  <div id=\"inputrow\">\n  <div id=\"micWrapper\" title=\"Snakk\">\n    <svg id=\"recordRing\" width=\"42\" height=\"42\" viewBox=\"0 0 42 42\">\n      <circle cx=\"21\" cy=\"21\" r=\"19\" class=\"bg\"><\/circle>\n      <circle cx=\"21\" cy=\"21\" r=\"19\" class=\"progress\"><\/circle>\n    <\/svg>\n    <div id=\"recordDot\"><\/div>\n    <span id=\"micIcon\">\ud83c\udf99\ufe0f<\/span>  <!-- <- VIKTIG: Ikonet m\u00e5 v\u00e6re med -->\n  <\/div>\n\n  <textarea id=\"prompt\" placeholder=\"Skriv eller snakk her\" rows=\"1\"><\/textarea>\n  <button id=\"sendBtn\" title=\"Send\">\u27a4<\/button>\n  <button id=\"hardStopBtn\" title=\"Stopp generering\">\u25a0<\/button>\n<\/div>\n<\/div>\n\n<script>\nlet chatHistory = [];\nlet currentUtterance, isPaused = false;\nlet activeAudio = null;\nlet stopRequested = false;\n\n\/\/ --- ELEMENTREFERANSER ---\nconst input = document.getElementById(\"prompt\");\nconst micBtn = document.getElementById(\"micBtn\");\nconst conversation = document.getElementById(\"conversation\");\nconst stopBtn = document.getElementById(\"hardStopBtn\");\nconst sendBtn = document.getElementById(\"sendBtn\");\n\n\/\/ Koble send-knappen til samme funksjon som Enter-tasten\nsendBtn.addEventListener(\"click\", e => {\n  e.preventDefault();   \/\/ hindrer sideoppdatering\n  send();               \/\/ kj\u00f8r send()-funksjonen\n});\n\n\/\/ --- AUTO-SCROLL (som i ChatGPT) ---\nlet autoScroll = true;\n\n\/\/ sl\u00e5 av autoscroll n\u00e5r brukeren scroller opp\nconversation.addEventListener(\"scroll\", () => {\n  const nearBottom = conversation.scrollHeight - conversation.scrollTop - conversation.clientHeight < 10;\n  autoScroll = nearBottom; \/\/ kun autoscroll hvis man er n\u00e6r bunnen\n});\n\n\/\/ funksjon for trygg scrolling\nfunction scrollToBottom() {\n  if (autoScroll) {\n    conversation.scrollTop = conversation.scrollHeight;\n  }\n}\n\n\/\/ --- HARD STOP ---\nstopBtn.addEventListener(\"click\", () => {\n  \/\/ marker at vi stopper tekstgenerering\n  stopRequested = true;\n\n  \/\/ Stopp aktiv lyd som spiller akkurat n\u00e5\n  if (activeAudio) {\n    try {\n      activeAudio.pause();\n      activeAudio.currentTime = 0;\n    } catch (e) {\n      console.warn(\"Audio already stopped\");\n    }\n    activeAudio = null;\n  }\n\n  \/\/ \ud83d\udd39 Simuler stopp-klikk p\u00e5 alle eksisterende lydkontroller\n  document.querySelectorAll(\".controls span:nth-child(3)\").forEach(btn => {\n    try {\n      btn.click(); \/\/ <- kj\u00f8r samme funksjon som STOP-knappen\n    } catch (e) {\n      console.warn(\"Stop button click failed\", e);\n    }\n  });\n\n  \/\/ La lydsystemet kunne starte p\u00e5 nytt straks etterp\u00e5\n  setTimeout(() => { stopRequested = false; }, 200);\n\n  console.warn(\"Hard Stop: alt stoppet, og stoppknapper trigget.\");\n});\n\n\/\/ --- ENTER sender melding ---\ninput.addEventListener(\"keydown\", e => {\n  if (e.key === \"Enter\" && !e.shiftKey) {\n    e.preventDefault();\n    send();\n  }\n});\n\n\/\/ --- Automatisk h\u00f8ydejustering for textarea ---\nfunction adjustTextareaHeight() {\n  input.style.height = \"auto\";\n  input.style.height = input.scrollHeight + \"px\";\n}\n\n\/\/ H\u00e5ndter b\u00e5de skriving og innliming\ninput.addEventListener(\"input\", adjustTextareaHeight);\ninput.addEventListener(\"paste\", () => {\n  \/\/ Vent litt for at paste skal fullf\u00f8res f\u00f8rst\n  setTimeout(adjustTextareaHeight, 0);\n});\n\n\/\/ --- SEND MELDING TIL OPENAI ---\nasync function send() {\n  const userText = input.value.trim();\n  if (!userText) return;\n\n  \/\/ \ud83d\udeab Begrens tekstlengde\n  const MAX_CHARS = 8000; \/\/ juster etter behov\n  if (userText.length > MAX_CHARS) {\n    alert(`Teksten er for lang (${userText.length} tegn). \nVennligst forkort til under ${MAX_CHARS} tegn.`);\n    return;\n  }\n\n  stopRequested = false;\n\n  const userMsg = document.createElement(\"div\");\n  userMsg.className = \"msg user\";\n  userMsg.textContent = userText;\n  conversation.appendChild(userMsg);\n  input.value = \"\";\n  input.style.height = \"38px\"; \/\/ reset h\u00f8yde til \u00e9n linje etter sending\n  scrollToBottom();\n\n  const aiMsg = document.createElement(\"div\");\n  aiMsg.className = \"msg ai\";\n  aiMsg.innerHTML = `\n    <div class=\"typing-indicator\">\n      <span><\/span><span><\/span><span><\/span>\n    <\/div>`;\n  conversation.appendChild(aiMsg);\n  scrollToBottom();\n\n  chatHistory.push({ role: \"user\", content: userText });\n\n  try {\n    const res = await fetch(\"\/wp-content\/themes\/gruble\/gn-api\/openai.php\", {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application\/json\" },\n      body: JSON.stringify({ messages: chatHistory })\n    });\n    const data = await res.json();\n    let text = data.reply || JSON.stringify(data, null, 2);\n\n    aiMsg.querySelector(\".typing-indicator\")?.remove();\n    if (!stopRequested) typeWriter(aiMsg, text);\n    chatHistory.push({ role: \"assistant\", content: text });\n  } catch (err) {\n    aiMsg.textContent = \"Feil ved tilkobling til OpenAI.\";\n  }\n}\n\n\/\/ --- TYPEWRITER-EFFEKT (skriver f\u00f8rst, aktiverer lenker etterp\u00e5) ---\nfunction typeWriter(el, text) {\n  \/\/ 1\ufe0f\u20e3 Midlertidig \u2013 vis alt som ren tekst under skriving\n  const originalText = text;\n\n  \/\/ 2\ufe0f\u20e3 Lag ren tekst uten HTML for selve animasjonen\n  const plainText = originalText\n    .replace(\/\\*\\*(.*?)\\*\\*\/g, '$1')\n    .replace(\/\\*(.*?)\\*\/g, '$1');\n\n  let i = 0;\n  const speed = 20;\n  el.textContent = \"\";\n\n  function write() {\n    if (stopRequested) return;\n    if (i < plainText.length) {\n      el.textContent = plainText.slice(0, i);\n      i++;\n      conversation.scrollTop = conversation.scrollHeight;\n      setTimeout(write, speed);\n    } else {\n      \/\/ 3\ufe0f\u20e3 Etter at alt er skrevet, aktiver HTML-formattering og lenker\n      let htmlText = originalText\n        .replace(\/\\*\\*(.*?)\\*\\*\/g, \"<strong>$1<\/strong>\")\n        .replace(\/\\*(.*?)\\*\/g, \"<em>$1<\/em>\")\n        .replace(\n          \/(https?:\\\/\\\/[^\\s]+)\/g,\n          '<a href=\"$1\" target=\"_blank\" rel=\"noopener noreferrer\">$1<\/a>'\n        )\n        .replace(\n          \/\\b([a-zA-Z0-9.-]+\\.[a-z]{2,})(?![^<]*>|[^<>]*<\\\/a>)\/g,\n          '<a href=\"https:\/\/$1\" target=\"_blank\" rel=\"noopener noreferrer\">$1<\/a>'\n        );\n\n      el.innerHTML = htmlText;\n      addAudioControls(el, htmlText);\n    }\n  }\n\n  write();\n}\n\n\/\/ --- PLAY \/ PAUSE \/ STOPP ---\nfunction addAudioControls(msgEl, text) {\n  const oldControls = msgEl.querySelector(\".controls\");\n  if (oldControls) oldControls.remove();\n\n  const controls = document.createElement(\"div\");\n  controls.className = \"controls\";\n  const playBtn = document.createElement(\"span\");\n  const pauseBtn = document.createElement(\"span\");\n  const stopBtn = document.createElement(\"span\");\n  \n\n  playBtn.textContent = \"\u25b6\ufe0f\";\n  pauseBtn.textContent = \"\u23f8\ufe0f\";\n  stopBtn.textContent = \"\u23f9\ufe0f\";\n\n  pauseBtn.style.display = \"none\";\n  stopBtn.style.display = \"none\";\n  controls.append(playBtn, pauseBtn, stopBtn);\n  msgEl.appendChild(controls);\n\n  let audio = null;\n\n  playBtn.addEventListener(\"click\", async () => {\n    try {\n      if (stopRequested) return;\n      if (audio && isPaused) {\n        audio.play();\n        isPaused = false;\n        playBtn.style.display = \"none\";\n        pauseBtn.style.display = \"inline\";\n        stopBtn.style.display = \"inline\";\n        return;\n      }\n\n      if (audio) { audio.pause(); audio.currentTime = 0; audio = null; }\n      if (activeAudio && !activeAudio.paused) {\n        activeAudio.pause(); activeAudio.currentTime = 0; activeAudio = null;\n      }\n\n      playBtn.textContent = \"\u23f3\";\n      pauseBtn.style.display = \"none\";\n      stopBtn.style.display = \"none\";\n\n      const res = await fetch(\"\/wp-content\/themes\/gruble\/gn-api\/tts.php\", {\n        method: \"POST\",\n        body: new URLSearchParams({ text: msgEl.textContent })\n      });\n      if (!res.ok) throw new Error(\"Kunne ikke hente lyd fra TTS.\");\n\n      const blob = await res.blob();\n      const url = URL.createObjectURL(blob);\n      audio = new Audio(url);\n      activeAudio = audio;\n\n      audio.addEventListener(\"canplaythrough\", () => {\n        if (audio.hasStarted) return;\n        audio.hasStarted = true;\n        playBtn.style.display = \"none\";\n        pauseBtn.style.display = \"inline\";\n        stopBtn.style.display = \"inline\";\n        audio.play().catch(err => console.error(\"Autoplay-feil:\", err));\n      }, { once: true });\n\n      audio.onended = () => {\n        playBtn.textContent = \"\u25b6\ufe0f\";\n        playBtn.style.display = \"inline\";\n        pauseBtn.style.display = \"none\";\n        stopBtn.style.display = \"none\";\n        activeAudio = null;\n        audio = null;\n        isPaused = false;\n      };\n    } catch (err) {\n      alert(\"Feil ved opplesing: \" + err.message);\n      playBtn.textContent = \"\u25b6\ufe0f\";\n    }\n  });\n\n  pauseBtn.addEventListener(\"click\", () => {\n    if (audio && !audio.paused) {\n      audio.pause();\n      isPaused = true;\n      activeAudio = audio;\n      playBtn.textContent = \"\u25b6\ufe0f\";\n      playBtn.style.display = \"inline\";\n      pauseBtn.style.display = \"none\";\n      stopBtn.style.display = \"inline\";\n    }\n  });\n\n  stopBtn.addEventListener(\"click\", () => {\n    if (audio) {\n      audio.pause(); audio.currentTime = 0; audio = null; activeAudio = null;\n    }\n    isPaused = false;\n    playBtn.textContent = \"\u25b6\ufe0f\";\n    playBtn.style.display = \"inline\";\n    pauseBtn.style.display = \"none\";\n    stopBtn.style.display = \"none\";\n  });\n}\n\n\/\/ --- WHISPER (TALE-TIL-TEKST) ---\nlet mediaRecorder, audioChunks = [], isRecording = false;\nlet stream = null;\n\nconst micWrapper = document.getElementById(\"micWrapper\");\nconst ring = micWrapper.querySelector(\"circle.progress\");\nconst dot = document.getElementById(\"recordDot\");\n\nmicWrapper.addEventListener(\"click\", async () => {\n  if (isRecording) {\n    stopRecording();\n    return;\n  }\n\n  try {\n    stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n    const audioContext = new AudioContext();\n    const source = audioContext.createMediaStreamSource(stream);\n    const analyser = audioContext.createAnalyser();\n    const processor = audioContext.createScriptProcessor(2048, 1, 1);\n    source.connect(analyser);\n    analyser.connect(processor);\n    processor.connect(audioContext.destination);\n\n    mediaRecorder = new MediaRecorder(stream);\n    audioChunks = [];\n    isRecording = true;\n    micWrapper.classList.add(\"recording\");\n    micWrapper.title = \"Opptak p\u00e5g\u00e5r...\";\n\n        \/\/ \ud83c\udfa7 Stillhetsdeteksjon (auto-stopp etter 4 sekunder uten lyd)\n    let silenceDuration = 0;\n    const silenceThreshold = 0.015; \/\/ sensitivitet: lavere = mer f\u00f8lsom\n    const maxSilence = 4000; \/\/ 4000 ms = 4 sekunder\n\n    processor.onaudioprocess = e => {\n      const input = e.inputBuffer.getChannelData(0);\n      let sum = 0.0;\n      for (let i = 0; i < input.length; i++) sum += input[i] * input[i];\n      const rms = Math.sqrt(sum \/ input.length);\n\n      if (rms < silenceThreshold) {\n        silenceDuration += e.inputBuffer.duration * 1000;\n        if (silenceDuration > maxSilence && isRecording) {\n          console.log(\"Auto-stopp etter stillhet (4 sekunder).\");\n          stopRecording(audioContext);\n        }\n      } else {\n        silenceDuration = 0; \/\/ nullstill n\u00e5r du snakker igjen\n      }\n    };\n\n    mediaRecorder.ondataavailable = e => audioChunks.push(e.data);\n\n    \/\/ Start fremdriftsring\n    ring.style.transition = \"none\";\n    ring.style.strokeDashoffset = 119.38;\n    void ring.offsetWidth; \/\/ tving reflow\n    ring.style.transition = \"stroke-dashoffset 20s linear\";\n    ring.style.strokeDashoffset = 0;\n\n    \/\/ Automatisk stopp etter 20 sekunder\n    const autoStop = setTimeout(() => stopRecording(audioContext), 20000);\n\n    mediaRecorder.onstop = async () => {\n  clearTimeout(autoStop);\n  micWrapper.classList.remove(\"recording\");   \/\/ <- viser ikon igjen\n  micWrapper.title = \"Snakk\";\n  ring.style.transition = \"none\";\n  ring.style.strokeDashoffset = 119.38;\n\n  processor.disconnect(); analyser.disconnect(); audioContext.close();\n  if (stream && stream.getTracks) stream.getTracks().forEach(track => track.stop());\n\n  const blob = new Blob(audioChunks, { type: \"audio\/webm\" });\n      const formData = new FormData();\n      formData.append(\"file\", blob, \"voice.webm\");\n\n      try {\n        const res = await fetch(\"\/wp-content\/themes\/gruble\/gn-api\/whisper.php\", {\n          method: \"POST\", body: formData\n        });\n        const data = await res.json();\n        if (data.text) {\n          input.value = data.text.trim();\n          adjustTextareaHeight(); \/\/ Juster h\u00f8yde n\u00e5r Whisper setter tekst\n          input.focus();\n          input.setSelectionRange(input.value.length, input.value.length);\n        } else {\n          alert(\"Whisper-feil: \" + (data.error || \"ukjent feil\"));\n        }\n      } catch (err) {\n        alert(\"Opplastingsfeil: \" + JSON.stringify(err, null, 2));\n      }\n    };\n\n    mediaRecorder.start();\n    console.log(\"Opptak startet \u2013 snakk n\u00e5.\");\n  } catch (e) {\n    alert(\"Mikrofon-tilgang avsl\u00e5tt eller ikke tilgjengelig.\");\n  }\n});\n\nfunction stopRecording(audioContext = null) {\n  if (isRecording) {\n    mediaRecorder.stop();\n    isRecording = false;\n    micWrapper.classList.remove(\"recording\");\n  }\n  if (stream && stream.getTracks) stream.getTracks().forEach(track => track.stop());\n  if (audioContext) audioContext.close();\n}\n<\/script>\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<div class=\"elementor-element elementor-element-cfd1aa3 elementor-widget elementor-widget-html\" data-id=\"cfd1aa3\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"html.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t<section id=\"gruble-info\" aria-label=\"Informasjon om Grublel\u00e6reren\">\n  <style>\n    \/* ---------- HOVEDSTIL FOR INFOKORTET ---------- *\/\n    #gruble-info .gl-card {\n      max-width: 760px;\n      margin: 24px auto;\n      background: #fff;\n      border: 1px solid #ddd;\n      border-radius: 14px;\n      box-shadow: 0 8px 24px rgba(0,0,0,0.06);\n      padding: 28px 26px;\n      font-family: system-ui, -apple-system, Segoe UI, Roboto, \"Helvetica Neue\", Arial, sans-serif !important;\n      color: #111 !important;\n      line-height: 1.8 !important;\n      font-size: 1.15rem !important;\n    }\n\n    \/* ---------- TITTEL OG BADGE ---------- *\/\n    #gruble-info .gl-title {\n      display: flex;\n      align-items: center;\n      justify-content: center; \/* midtstill overskrift *\/\n      gap: 12px;\n      margin: 0 0 14px 0;\n      font-size: 2rem !important;\n      line-height: 1.3 !important;\n      color: #2d6cdf !important;\n      font-weight: 800 !important;\n      letter-spacing: 0.2px;\n      text-align: center;\n    }\n\n    #gruble-info .gl-badge {\n      display: inline-block;\n      font-size: 1rem !important;\n      font-weight: 700;\n      color: #2d6cdf;\n      background: #e9f0ff;\n      border: 1px solid #cfe0ff;\n      border-radius: 999px;\n      padding: 6px 14px;\n      letter-spacing: .3px;\n    }\n\n    \/* ---------- BR\u00d8DTEKST ---------- *\/\n    #gruble-info .gl-body {\n      font-size: 1.2rem !important;\n      line-height: 1.9 !important;\n      margin: 10px 0 0 0;\n      color: #222 !important;\n    }\n\n    #gruble-info .gl-body strong {\n      font-weight: 700;\n    }\n\n    \/* ---------- \"GODE R\u00c5D\"-BOKS SOM KNAPP ---------- *\/\n    #gruble-info .gl-advice {\n      margin-bottom: 24px;\n      padding: 24px 28px;\n      border: 2px dashed #cfe0ff;\n      background: #f8fbff;\n      border-radius: 12px;\n      text-align: center;\n      box-shadow: 0 3px 10px rgba(0,0,0,0.05);\n      cursor: pointer;\n      transition: all 0.3s ease;\n    }\n\n    #gruble-info .gl-advice:hover {\n      background: #f0f6ff;\n      transform: scale(1.01);\n      box-shadow: 0 5px 14px rgba(0,0,0,0.08);\n    }\n\n    #gruble-info .gl-advice h4 {\n      margin: 0 0 10px 0;\n      font-size: 1.8rem;\n      color: #2d6cdf;\n      font-weight: 800;\n    }\n\n    #gruble-info .gl-advice p {\n      margin: 0;\n      color: #333;\n      font-size: 1.25rem;\n      line-height: 1.6;\n    }\n\n    \/* ---------- MODAL ---------- *\/\n    #tipsModal {\n      display: none;\n      position: fixed;\n      z-index: 10000;\n      left: 0; top: 0;\n      width: 100%; height: 100%;\n      background-color: rgba(0,0,0,0.5);\n      backdrop-filter: blur(3px);\n    }\n\n    .modal-content {\n      background-color: #fff;\n      margin: 8% auto;\n      padding: 25px 30px;\n      border-radius: 12px;\n      width: 90%;\n      max-width: 550px;\n      color: #111;\n      font-family: system-ui, sans-serif;\n      line-height: 1.6;\n      box-shadow: 0 8px 24px rgba(0,0,0,0.3);\n      animation: fadeIn 0.3s ease;\n      max-height: 80vh;           \/* gj\u00f8r modalen scrollbar *\/\n      overflow-y: auto;           \/* scroll p\u00e5 innholdet *\/\n      scrollbar-width: thin;\n      scrollbar-color: #2d6cdf #f1f1f1;\n    }\n\n    .modal-content::-webkit-scrollbar {\n      width: 8px;\n    }\n    .modal-content::-webkit-scrollbar-thumb {\n      background-color: #2d6cdf;\n      border-radius: 10px;\n    }\n\n    @keyframes fadeIn {\n      from { opacity: 0; transform: scale(0.95); }\n      to { opacity: 1; transform: scale(1); }\n    }\n\n    .modal-content h2 {\n      margin-top: 0;\n      color: #2d6cdf;\n      text-align: center;\n      font-size: 24px;\n    }\n\n    .modal-content p {\n      font-size: 16px;\n      color: #333;\n      margin-bottom: 10px;\n    }\n\n    .modal-content ol {\n      padding-left: 22px;\n      text-align: left;\n    }\n\n    .modal-content li {\n      margin-bottom: 8px;\n    }\n\n    #closeModal {\n      color: #aaa;\n      float: right;\n      font-size: 26px;\n      font-weight: bold;\n      cursor: pointer;\n    }\n    #closeModal:hover { color: #000; }\n\n    \/* ---------- RESPONSIVT ---------- *\/\n    @media (max-width: 480px) {\n      #gruble-info .gl-card { padding: 22px; font-size: 1.05rem !important; }\n      #gruble-info .gl-title { font-size: 1.6rem !important; }\n      #gruble-info .gl-advice h4 { font-size: 1.5rem; }\n      #gruble-info .gl-advice p { font-size: 1.1rem; }\n    }\n  <\/style>\n\n  <div class=\"gl-card\">\n    <div class=\"gl-title\">\n      \ud83e\udde0 Bli med og test!\n      <span class=\"gl-badge\">Testversjon<\/span>\n    <\/div>\n\n    <!-- \ud83d\udca1 Klikkbar \"Gode r\u00e5d\"-boks -->\n    <div class=\"gl-advice\" id=\"tipsBox\">\n      <h4>\ud83d\udca1 Gode r\u00e5d<\/h4>\n      <p>Vil du vite hvordan du kan f\u00e5 mest mulig ut av Grublel\u00e6reren? Trykk her for \u00e5 lese nyttige tips!<\/p>\n    <\/div>\n\n    <div class=\"gl-body\">\n      Grublel\u00e6reren er en chatbot basert p\u00e5 OpenAIs <strong>GPT-4o-mini<\/strong>.<br><br>\n      Den kan hjelpe med mye, men i likhet med alle spr\u00e5kmodeller gj\u00f8r den ogs\u00e5 feil. Du m\u00e5 alltid sjekke viktig\n      informasjon du f\u00e5r med andre og trygge kilder. <br><br>Foreldre, l\u00e6rere, kvalitetssikrede oppslagsverk og andre gode\n      hjelpere er viktige og gode \u00e5 ha for \u00e5 dobbeltsjekke de svarene du f\u00e5r fra Grublel\u00e6reren. Men forh\u00e5pentligvis\n      vil det meste v\u00e6re til god og morsom hjelp for deg. <br><br>Takk for at du tester. Lykke til med l\u00e6ringen!\n    <\/div>\n  <\/div>\n\n  <!-- \ud83d\udd39 Modal med tips -->\n  <div id=\"tipsModal\">\n    <div class=\"modal-content\">\n      <span id=\"closeModal\">&times;<\/span>\n      <h2>Gode r\u00e5d for \u00e5 bruke Grublel\u00e6reren<\/h2>\n      <p>Her er noen tips til hvordan du kan l\u00e6re mest mulig n\u00e5r du bruker denne tjenesten:<\/p>\n      <ol>\n        <li><strong>Still oppf\u00f8lgingssp\u00f8rsm\u00e5l:<\/strong> Hvis du ikke helt forst\u00e5r noe, kan du sp\u00f8rre hva som menes med bestemte ord eller uttrykk.<\/li>\n        <li><strong>Bruk eksempler:<\/strong> Be om konkrete eksempler som kan illustrere svaret bedre.<\/li>\n        <li><strong>Kategoriser informasjonen:<\/strong> Del temaet opp i ulike deler for \u00e5 gj\u00f8re det lettere \u00e5 forst\u00e5.<\/li>\n        <li><strong>Sammenlign med kjent informasjon:<\/strong> Be om \u00e5 f\u00e5 informasjonen sammenlignet med noe du allerede vet.<\/li>\n        <li><strong>Foresl\u00e5 l\u00e6ringsmetoder:<\/strong> Sp\u00f8r om ulike m\u00e5ter \u00e5 l\u00e6re stoffet p\u00e5 \u2013 videoer, b\u00f8ker, aktiviteter.<\/li>\n        <li><strong>Visualisering:<\/strong> Be om diagrammer eller illustrasjoner som viser sammenhenger. (Jeg kan dessverre bare beskrive bilder og diagrammer med ord. Men jeg anbefaler \u00e5 pr\u00f8ve det likevel, det kan v\u00e6re ganske l\u00e6rerikt.)<\/li>\n        <li><strong>Praktiske anvendelser:<\/strong> Sp\u00f8r hvordan kunnskapen kan brukes i hverdagen.<\/li>\n        <li><strong>Bekreft kilder:<\/strong> Be om tips til b\u00f8ker, nettsider eller artikler der du kan lese mer.<\/li>\n      <\/ol>\n    <\/div>\n  <\/div>\n\n  <script>\n    const tipsBox = document.getElementById(\"tipsBox\");\n    const tipsModal = document.getElementById(\"tipsModal\");\n    const closeModal = document.getElementById(\"closeModal\");\n\n    tipsBox.addEventListener(\"click\", () => {\n      tipsModal.style.display = \"block\";\n    });\n\n    closeModal.addEventListener(\"click\", () => {\n      tipsModal.style.display = \"none\";\n    });\n\n    window.addEventListener(\"click\", e => {\n      if (e.target === tipsModal) tipsModal.style.display = \"none\";\n    });\n  <\/script>\n<\/section>\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t<\/section>\n\t\t\t\t<\/div>\n\t\t","protected":false},"excerpt":{"rendered":"<p>\ud83d\udcac Grublel\u00e6reren \ud83c\udf99\ufe0f \u27a4 \u25a0 \ud83e\udde0 Bli med og test! Testversjon \ud83d\udca1 Gode r\u00e5d Vil du vite hvordan du kan f\u00e5 mest mulig ut av Grublel\u00e6reren? Trykk her for \u00e5 lese nyttige tips! Grublel\u00e6reren er en chatbot basert p\u00e5 OpenAIs GPT-4o-mini. Den kan hjelpe med mye, men i likhet med alle spr\u00e5kmodeller gj\u00f8r den ogs\u00e5 [&hellip;]<\/p>\n","protected":false},"author":2,"featured_media":0,"parent":0,"menu_order":2,"comment_status":"closed","ping_status":"closed","template":"elementor_canvas","meta":{"footnotes":""},"categories":[],"tags":[],"folder":[],"class_list":["post-39572","page","type-page","status-publish","hentry"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v27.6 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Grublel\u00e6rer - Gruble.net<\/title>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/gruble.net\/grublelarer\/\" \/>\n<meta property=\"og:locale\" content=\"nb_NO\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Grublel\u00e6rer - Gruble.net\" \/>\n<meta property=\"og:description\" content=\"\ud83d\udcac Grublel\u00e6reren \ud83c\udf99\ufe0f \u27a4 \u25a0 \ud83e\udde0 Bli med og test! Testversjon \ud83d\udca1 Gode r\u00e5d Vil du vite hvordan du kan f\u00e5 mest mulig ut av Grublel\u00e6reren? Trykk her for \u00e5 lese nyttige tips! Grublel\u00e6reren er en chatbot basert p\u00e5 OpenAIs GPT-4o-mini. Den kan hjelpe med mye, men i likhet med alle spr\u00e5kmodeller gj\u00f8r den ogs\u00e5 [&hellip;]\" \/>\n<meta property=\"og:url\" content=\"https:\/\/gruble.net\/grublelarer\/\" \/>\n<meta property=\"og:site_name\" content=\"Gruble.net\" \/>\n<meta property=\"article:publisher\" content=\"https:\/\/www.facebook.com\/Gruble.net\" \/>\n<meta property=\"article:modified_time\" content=\"2026-01-28T16:26:02+00:00\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Ansl. lesetid\" \/>\n\t<meta name=\"twitter:data1\" content=\"3 minutter\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/gruble.net\\\/grublelarer\\\/\",\"url\":\"https:\\\/\\\/gruble.net\\\/grublelarer\\\/\",\"name\":\"Grublel\u00e6rer - Gruble.net\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/gruble.net\\\/#website\"},\"datePublished\":\"2025-10-30T06:41:49+00:00\",\"dateModified\":\"2026-01-28T16:26:02+00:00\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/gruble.net\\\/grublelarer\\\/#breadcrumb\"},\"inLanguage\":\"nb-NO\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/gruble.net\\\/grublelarer\\\/\"]}]},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/gruble.net\\\/grublelarer\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Hjem\",\"item\":\"https:\\\/\\\/gruble.net\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Grublel\u00e6rer\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/gruble.net\\\/#website\",\"url\":\"https:\\\/\\\/gruble.net\\\/\",\"name\":\"Gruble.net\",\"description\":\"Spill deg til kunnskap og sett kunnskapen p\u00e5 spill!\",\"publisher\":{\"@id\":\"https:\\\/\\\/gruble.net\\\/#organization\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/gruble.net\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"nb-NO\"},{\"@type\":\"Organization\",\"@id\":\"https:\\\/\\\/gruble.net\\\/#organization\",\"name\":\"Gruble.net\",\"url\":\"https:\\\/\\\/gruble.net\\\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"nb-NO\",\"@id\":\"https:\\\/\\\/gruble.net\\\/#\\\/schema\\\/logo\\\/image\\\/\",\"url\":\"https:\\\/\\\/gruble.net\\\/wp-content\\\/uploads\\\/logo-1-e1717058548444.png\",\"contentUrl\":\"https:\\\/\\\/gruble.net\\\/wp-content\\\/uploads\\\/logo-1-e1717058548444.png\",\"width\":483,\"height\":149,\"caption\":\"Gruble.net\"},\"image\":{\"@id\":\"https:\\\/\\\/gruble.net\\\/#\\\/schema\\\/logo\\\/image\\\/\"},\"sameAs\":[\"https:\\\/\\\/www.facebook.com\\\/Gruble.net\"]}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Grublel\u00e6rer - Gruble.net","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/gruble.net\/grublelarer\/","og_locale":"nb_NO","og_type":"article","og_title":"Grublel\u00e6rer - Gruble.net","og_description":"\ud83d\udcac Grublel\u00e6reren \ud83c\udf99\ufe0f \u27a4 \u25a0 \ud83e\udde0 Bli med og test! Testversjon \ud83d\udca1 Gode r\u00e5d Vil du vite hvordan du kan f\u00e5 mest mulig ut av Grublel\u00e6reren? Trykk her for \u00e5 lese nyttige tips! Grublel\u00e6reren er en chatbot basert p\u00e5 OpenAIs GPT-4o-mini. Den kan hjelpe med mye, men i likhet med alle spr\u00e5kmodeller gj\u00f8r den ogs\u00e5 [&hellip;]","og_url":"https:\/\/gruble.net\/grublelarer\/","og_site_name":"Gruble.net","article_publisher":"https:\/\/www.facebook.com\/Gruble.net","article_modified_time":"2026-01-28T16:26:02+00:00","twitter_card":"summary_large_image","twitter_misc":{"Ansl. lesetid":"3 minutter"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"WebPage","@id":"https:\/\/gruble.net\/grublelarer\/","url":"https:\/\/gruble.net\/grublelarer\/","name":"Grublel\u00e6rer - Gruble.net","isPartOf":{"@id":"https:\/\/gruble.net\/#website"},"datePublished":"2025-10-30T06:41:49+00:00","dateModified":"2026-01-28T16:26:02+00:00","breadcrumb":{"@id":"https:\/\/gruble.net\/grublelarer\/#breadcrumb"},"inLanguage":"nb-NO","potentialAction":[{"@type":"ReadAction","target":["https:\/\/gruble.net\/grublelarer\/"]}]},{"@type":"BreadcrumbList","@id":"https:\/\/gruble.net\/grublelarer\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Hjem","item":"https:\/\/gruble.net\/"},{"@type":"ListItem","position":2,"name":"Grublel\u00e6rer"}]},{"@type":"WebSite","@id":"https:\/\/gruble.net\/#website","url":"https:\/\/gruble.net\/","name":"Gruble.net","description":"Spill deg til kunnskap og sett kunnskapen p\u00e5 spill!","publisher":{"@id":"https:\/\/gruble.net\/#organization"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/gruble.net\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"nb-NO"},{"@type":"Organization","@id":"https:\/\/gruble.net\/#organization","name":"Gruble.net","url":"https:\/\/gruble.net\/","logo":{"@type":"ImageObject","inLanguage":"nb-NO","@id":"https:\/\/gruble.net\/#\/schema\/logo\/image\/","url":"https:\/\/gruble.net\/wp-content\/uploads\/logo-1-e1717058548444.png","contentUrl":"https:\/\/gruble.net\/wp-content\/uploads\/logo-1-e1717058548444.png","width":483,"height":149,"caption":"Gruble.net"},"image":{"@id":"https:\/\/gruble.net\/#\/schema\/logo\/image\/"},"sameAs":["https:\/\/www.facebook.com\/Gruble.net"]}]}},"_links":{"self":[{"href":"https:\/\/gruble.net\/wp-json\/wp\/v2\/pages\/39572","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/gruble.net\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/gruble.net\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/gruble.net\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/gruble.net\/wp-json\/wp\/v2\/comments?post=39572"}],"version-history":[{"count":317,"href":"https:\/\/gruble.net\/wp-json\/wp\/v2\/pages\/39572\/revisions"}],"predecessor-version":[{"id":42011,"href":"https:\/\/gruble.net\/wp-json\/wp\/v2\/pages\/39572\/revisions\/42011"}],"wp:attachment":[{"href":"https:\/\/gruble.net\/wp-json\/wp\/v2\/media?parent=39572"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/gruble.net\/wp-json\/wp\/v2\/categories?post=39572"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/gruble.net\/wp-json\/wp\/v2\/tags?post=39572"},{"taxonomy":"folder","embeddable":true,"href":"https:\/\/gruble.net\/wp-json\/wp\/v2\/folder?post=39572"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}