|
|
<!DOCTYPE html> |
|
|
|
|
|
<html lang="en"> |
|
|
|
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Paper</title> |
|
|
<link rel="preconnect" href="https://fonts.googleapis.com"> |
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" |
|
|
rel="stylesheet"> |
|
|
<style> |
|
|
:root { |
|
|
|
|
|
--bg-primary: #0f0f1a; |
|
|
--bg-secondary: #1a1a2e; |
|
|
--bg-tertiary: #252540; |
|
|
--bg-card: rgba(25, 25, 45, 0.95); |
|
|
--text-primary: #ffffff; |
|
|
--text-secondary: #b8b8d0; |
|
|
--text-muted: #7070a0; |
|
|
--accent-pink: #ff6b9d; |
|
|
--accent-purple: #a855f7; |
|
|
--accent-blue: #3b82f6; |
|
|
--accent-yellow: #fbbf24; |
|
|
--gradient-primary: linear-gradient(135deg, #ff6b9d 0%, #a855f7 50%, #3b82f6 100%); |
|
|
--gradient-hover: linear-gradient(135deg, #ff4d8a 0%, #9333ea 50%, #2563eb 100%); |
|
|
--success: #22c55e; |
|
|
--warning: #f59e0b; |
|
|
--error: #ef4444; |
|
|
--border: rgba(168, 85, 247, 0.25); |
|
|
--shadow-soft: 0 4px 20px rgba(0, 0, 0, 0.3); |
|
|
--shadow-glow: 0 8px 40px rgba(168, 85, 247, 0.15); |
|
|
--radius: 24px; |
|
|
--radius-sm: 16px; |
|
|
--transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); |
|
|
} |
|
|
|
|
|
* { |
|
|
margin: 0; |
|
|
padding: 0; |
|
|
box-sizing: border-box; |
|
|
} |
|
|
|
|
|
html, |
|
|
body { |
|
|
height: 100%; |
|
|
} |
|
|
|
|
|
body { |
|
|
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif; |
|
|
background: linear-gradient(135deg, #0f0f1a 0%, #1a1025 50%, #0f1520 100%); |
|
|
background-attachment: fixed; |
|
|
color: var(--text-primary); |
|
|
line-height: 1.6; |
|
|
position: relative; |
|
|
} |
|
|
|
|
|
body::before { |
|
|
content: ''; |
|
|
position: fixed; |
|
|
top: -50%; |
|
|
left: -50%; |
|
|
width: 200%; |
|
|
height: 200%; |
|
|
background: |
|
|
radial-gradient(circle at 20% 30%, rgba(255, 107, 157, 0.25) 0%, transparent 40%), |
|
|
radial-gradient(circle at 80% 70%, rgba(59, 130, 246, 0.2) 0%, transparent 40%), |
|
|
radial-gradient(circle at 50% 50%, rgba(168, 85, 247, 0.15) 0%, transparent 50%); |
|
|
animation: floatBg 20s ease-in-out infinite; |
|
|
pointer-events: none; |
|
|
z-index: 1; |
|
|
} |
|
|
|
|
|
@keyframes floatBg { |
|
|
|
|
|
0%, |
|
|
100% { |
|
|
transform: translate(0, 0) rotate(0deg); |
|
|
} |
|
|
|
|
|
33% { |
|
|
transform: translate(2%, 2%) rotate(1deg); |
|
|
} |
|
|
|
|
|
66% { |
|
|
transform: translate(-1%, 1%) rotate(-1deg); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.login-screen { |
|
|
display: flex; |
|
|
justify-content: center; |
|
|
align-items: center; |
|
|
height: 100%; |
|
|
padding: 20px; |
|
|
position: relative; |
|
|
z-index: 2; |
|
|
} |
|
|
|
|
|
|
|
|
.card-container { |
|
|
width: 100%; |
|
|
max-width: 420px; |
|
|
perspective: 1000px; |
|
|
} |
|
|
|
|
|
.card-inner { |
|
|
position: relative; |
|
|
width: 100%; |
|
|
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1); |
|
|
transform-style: preserve-3d; |
|
|
} |
|
|
|
|
|
.card-container.flipped .card-inner { |
|
|
transform: rotateY(180deg); |
|
|
} |
|
|
|
|
|
.card-front, |
|
|
.card-back { |
|
|
text-align: center; |
|
|
padding: 40px 36px; |
|
|
border-radius: var(--radius); |
|
|
background: var(--bg-card); |
|
|
border: 2px solid var(--border); |
|
|
box-shadow: var(--shadow-soft), var(--shadow-glow); |
|
|
backface-visibility: hidden; |
|
|
-webkit-backface-visibility: hidden; |
|
|
} |
|
|
|
|
|
.card-front { |
|
|
position: relative; |
|
|
z-index: 2; |
|
|
animation: bounceIn 0.6s var(--transition); |
|
|
} |
|
|
|
|
|
.card-back { |
|
|
position: absolute; |
|
|
top: 0; |
|
|
left: 0; |
|
|
right: 0; |
|
|
bottom: 0; |
|
|
transform: rotateY(180deg); |
|
|
z-index: 1; |
|
|
} |
|
|
|
|
|
@keyframes bounceIn { |
|
|
0% { |
|
|
opacity: 0; |
|
|
transform: scale(0.9) translateY(20px); |
|
|
} |
|
|
|
|
|
50% { |
|
|
transform: scale(1.02) translateY(-5px); |
|
|
} |
|
|
|
|
|
100% { |
|
|
opacity: 1; |
|
|
transform: scale(1) translateY(0); |
|
|
} |
|
|
} |
|
|
|
|
|
.login-box h1 { |
|
|
margin-bottom: 12px; |
|
|
font-weight: 800; |
|
|
font-size: 42px; |
|
|
letter-spacing: -1px; |
|
|
font-family: 'Plus Jakarta Sans', sans-serif; |
|
|
background: var(--gradient-primary); |
|
|
-webkit-background-clip: text; |
|
|
-webkit-text-fill-color: transparent; |
|
|
background-clip: text; |
|
|
} |
|
|
|
|
|
.login-box h1::after { |
|
|
content: ' ✨'; |
|
|
-webkit-text-fill-color: initial; |
|
|
} |
|
|
|
|
|
.login-subtitle { |
|
|
color: var(--text-secondary); |
|
|
font-size: 16px; |
|
|
margin-bottom: 32px; |
|
|
font-weight: 500; |
|
|
line-height: 1.6; |
|
|
} |
|
|
|
|
|
|
|
|
.info-icon { |
|
|
position: absolute; |
|
|
top: 16px; |
|
|
right: 16px; |
|
|
width: 28px; |
|
|
height: 28px; |
|
|
border-radius: 50%; |
|
|
background: var(--bg-tertiary); |
|
|
border: 1px solid var(--border); |
|
|
color: var(--text-muted); |
|
|
font-size: 14px; |
|
|
font-weight: 600; |
|
|
cursor: pointer; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
transition: var(--transition); |
|
|
font-family: serif; |
|
|
z-index: 5; |
|
|
} |
|
|
|
|
|
.info-icon:hover { |
|
|
background: var(--accent-purple); |
|
|
color: white; |
|
|
border-color: var(--accent-purple); |
|
|
transform: scale(1.1); |
|
|
} |
|
|
|
|
|
.back-icon { |
|
|
position: absolute; |
|
|
top: 16px; |
|
|
right: 16px; |
|
|
width: 28px; |
|
|
height: 28px; |
|
|
border-radius: 50%; |
|
|
background: var(--bg-tertiary); |
|
|
border: 1px solid var(--border); |
|
|
color: var(--text-muted); |
|
|
font-size: 18px; |
|
|
font-weight: 300; |
|
|
cursor: pointer; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
transition: var(--transition); |
|
|
line-height: 1; |
|
|
} |
|
|
|
|
|
.back-icon:hover { |
|
|
background: var(--accent-pink); |
|
|
color: white; |
|
|
border-color: var(--accent-pink); |
|
|
transform: scale(1.1); |
|
|
} |
|
|
|
|
|
.info-title { |
|
|
font-size: 22px; |
|
|
font-weight: 700; |
|
|
color: var(--text-primary); |
|
|
margin-bottom: 20px; |
|
|
background: var(--gradient-primary); |
|
|
-webkit-background-clip: text; |
|
|
-webkit-text-fill-color: transparent; |
|
|
background-clip: text; |
|
|
} |
|
|
|
|
|
.info-list { |
|
|
list-style: none; |
|
|
padding: 0; |
|
|
margin: 0 0 24px 0; |
|
|
text-align: left; |
|
|
max-height: 200px; |
|
|
overflow-y: auto; |
|
|
padding-right: 8px; |
|
|
} |
|
|
|
|
|
|
|
|
.info-list::-webkit-scrollbar { |
|
|
width: 6px; |
|
|
} |
|
|
|
|
|
.info-list::-webkit-scrollbar-track { |
|
|
background: var(--bg-tertiary); |
|
|
border-radius: 6px; |
|
|
} |
|
|
|
|
|
.info-list::-webkit-scrollbar-thumb { |
|
|
background: var(--gradient-primary); |
|
|
border-radius: 6px; |
|
|
} |
|
|
|
|
|
.info-list::-webkit-scrollbar-thumb:hover { |
|
|
background: var(--gradient-hover); |
|
|
} |
|
|
|
|
|
.info-list li { |
|
|
color: var(--text-secondary); |
|
|
font-size: 13px; |
|
|
line-height: 1.7; |
|
|
padding: 10px 0; |
|
|
padding-left: 24px; |
|
|
position: relative; |
|
|
border-bottom: 1px solid var(--border); |
|
|
} |
|
|
|
|
|
.info-list li:last-child { |
|
|
border-bottom: none; |
|
|
} |
|
|
|
|
|
.info-list li::before { |
|
|
content: '✦'; |
|
|
position: absolute; |
|
|
left: 0; |
|
|
color: var(--accent-purple); |
|
|
} |
|
|
|
|
|
.info-list li strong { |
|
|
color: var(--text-primary); |
|
|
} |
|
|
|
|
|
.info-link { |
|
|
display: inline-flex; |
|
|
align-items: center; |
|
|
gap: 6px; |
|
|
color: var(--accent-blue); |
|
|
text-decoration: none; |
|
|
font-weight: 600; |
|
|
font-size: 13px; |
|
|
transition: var(--transition); |
|
|
padding: 10px 16px; |
|
|
background: var(--bg-tertiary); |
|
|
border-radius: 50px; |
|
|
border: 1px solid var(--border); |
|
|
} |
|
|
|
|
|
.info-link:hover { |
|
|
color: white; |
|
|
background: var(--accent-blue); |
|
|
border-color: var(--accent-blue); |
|
|
} |
|
|
|
|
|
.input-group { |
|
|
position: relative; |
|
|
margin-bottom: 24px; |
|
|
} |
|
|
|
|
|
.password-input { |
|
|
width: 100%; |
|
|
padding: 18px 24px; |
|
|
font-size: 17px; |
|
|
border: 2px solid var(--border); |
|
|
border-radius: var(--radius-sm); |
|
|
background: var(--bg-secondary); |
|
|
color: var(--text-primary); |
|
|
text-align: center; |
|
|
letter-spacing: 3px; |
|
|
outline: none; |
|
|
transition: var(--transition); |
|
|
font-family: 'Plus Jakarta Sans', monospace; |
|
|
font-weight: 600; |
|
|
} |
|
|
|
|
|
.password-input:focus { |
|
|
border-color: var(--accent-purple); |
|
|
box-shadow: 0 0 0 4px rgba(168, 85, 247, 0.25), 0 0 30px rgba(168, 85, 247, 0.15); |
|
|
background: var(--bg-tertiary); |
|
|
transform: scale(1.02); |
|
|
} |
|
|
|
|
|
.password-input::placeholder { |
|
|
color: var(--text-muted); |
|
|
letter-spacing: normal; |
|
|
font-family: 'Plus Jakarta Sans', sans-serif; |
|
|
font-weight: 500; |
|
|
} |
|
|
|
|
|
.enter-btn { |
|
|
width: 100%; |
|
|
padding: 18px 32px; |
|
|
background: var(--gradient-primary); |
|
|
color: #ffffff; |
|
|
border: none; |
|
|
border-radius: var(--radius-sm); |
|
|
font-size: 16px; |
|
|
font-weight: 700; |
|
|
cursor: pointer; |
|
|
transition: var(--transition); |
|
|
position: relative; |
|
|
overflow: hidden; |
|
|
text-transform: uppercase; |
|
|
letter-spacing: 1.5px; |
|
|
font-family: 'Plus Jakarta Sans', sans-serif; |
|
|
box-shadow: 0 6px 25px rgba(255, 107, 157, 0.35); |
|
|
} |
|
|
|
|
|
.enter-btn:hover:not(:disabled) { |
|
|
transform: translateY(-3px) scale(1.02); |
|
|
box-shadow: 0 10px 35px rgba(255, 107, 157, 0.45); |
|
|
background: var(--gradient-hover); |
|
|
} |
|
|
|
|
|
.enter-btn:active { |
|
|
transform: translateY(0) scale(0.98); |
|
|
} |
|
|
|
|
|
.enter-btn:disabled { |
|
|
opacity: 0.6; |
|
|
cursor: not-allowed; |
|
|
transform: none; |
|
|
} |
|
|
|
|
|
.error { |
|
|
color: var(--error); |
|
|
margin-top: 16px; |
|
|
font-size: 14px; |
|
|
min-height: 20px; |
|
|
font-weight: 500; |
|
|
} |
|
|
|
|
|
|
|
|
.editor-screen { |
|
|
display: none; |
|
|
height: 100%; |
|
|
background: transparent; |
|
|
flex-direction: column; |
|
|
position: relative; |
|
|
z-index: 2; |
|
|
} |
|
|
|
|
|
.header { |
|
|
padding: 16px 24px; |
|
|
background: var(--bg-card); |
|
|
border-bottom: 2px solid var(--border); |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: center; |
|
|
backdrop-filter: blur(10px); |
|
|
position: relative; |
|
|
flex-shrink: 0; |
|
|
} |
|
|
|
|
|
.header-left { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 16px; |
|
|
} |
|
|
|
|
|
.app-title { |
|
|
font-size: 26px; |
|
|
font-weight: 800; |
|
|
font-family: 'Plus Jakarta Sans', sans-serif; |
|
|
letter-spacing: -0.5px; |
|
|
background: var(--gradient-primary); |
|
|
-webkit-background-clip: text; |
|
|
-webkit-text-fill-color: transparent; |
|
|
background-clip: text; |
|
|
cursor: pointer; |
|
|
transition: var(--transition); |
|
|
} |
|
|
|
|
|
.app-title:hover { |
|
|
transform: scale(1.05); |
|
|
} |
|
|
|
|
|
.header-right { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 12px; |
|
|
flex-wrap: wrap; |
|
|
} |
|
|
|
|
|
.save-status { |
|
|
font-size: 12px; |
|
|
font-weight: 700; |
|
|
padding: 10px 18px; |
|
|
border-radius: 50px; |
|
|
background: var(--bg-tertiary); |
|
|
color: var(--text-secondary); |
|
|
border: 2px solid var(--border); |
|
|
min-width: 90px; |
|
|
text-align: center; |
|
|
font-family: 'Plus Jakarta Sans', sans-serif; |
|
|
text-transform: uppercase; |
|
|
letter-spacing: 0.5px; |
|
|
transition: var(--transition); |
|
|
} |
|
|
|
|
|
.save-status.saving { |
|
|
background: var(--gradient-primary); |
|
|
color: white; |
|
|
border-color: transparent; |
|
|
box-shadow: 0 4px 20px rgba(168, 85, 247, 0.3); |
|
|
animation: wiggle 0.5s ease-in-out infinite; |
|
|
} |
|
|
|
|
|
@keyframes wiggle { |
|
|
|
|
|
0%, |
|
|
100% { |
|
|
transform: rotate(-2deg); |
|
|
} |
|
|
|
|
|
50% { |
|
|
transform: rotate(2deg); |
|
|
} |
|
|
} |
|
|
|
|
|
.save-status.saved { |
|
|
background: linear-gradient(135deg, var(--success) 0%, #10b981 100%); |
|
|
color: white; |
|
|
border-color: transparent; |
|
|
box-shadow: 0 4px 15px rgba(34, 197, 94, 0.3); |
|
|
} |
|
|
|
|
|
.word-count { |
|
|
font-size: 14px; |
|
|
color: var(--text-secondary); |
|
|
font-weight: 600; |
|
|
font-family: 'Plus Jakarta Sans', sans-serif; |
|
|
background: var(--bg-tertiary); |
|
|
padding: 6px 14px; |
|
|
border-radius: 20px; |
|
|
} |
|
|
|
|
|
.editor-container { |
|
|
flex: 1; |
|
|
position: relative; |
|
|
margin: 20px; |
|
|
border-radius: var(--radius); |
|
|
background: var(--bg-card); |
|
|
border: 2px solid var(--border); |
|
|
box-shadow: var(--shadow-soft); |
|
|
animation: paperUnfold 0.5s ease-out; |
|
|
} |
|
|
|
|
|
.editor { |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
padding: 28px 32px; |
|
|
border: none; |
|
|
outline: none; |
|
|
font-family: 'Plus Jakarta Sans', sans-serif; |
|
|
font-size: 17px; |
|
|
font-weight: 500; |
|
|
line-height: 1.9; |
|
|
resize: none; |
|
|
background: transparent; |
|
|
color: var(--text-primary); |
|
|
position: relative; |
|
|
z-index: 2; |
|
|
border-radius: var(--radius); |
|
|
caret-color: var(--accent-purple); |
|
|
} |
|
|
|
|
|
.editor::placeholder { |
|
|
color: var(--text-muted); |
|
|
font-weight: 500; |
|
|
} |
|
|
|
|
|
|
|
|
.editor::-webkit-scrollbar { |
|
|
width: 10px; |
|
|
} |
|
|
|
|
|
.editor::-webkit-scrollbar-track { |
|
|
background: var(--bg-tertiary); |
|
|
border-radius: 10px; |
|
|
} |
|
|
|
|
|
.editor::-webkit-scrollbar-thumb { |
|
|
background: var(--gradient-primary); |
|
|
border-radius: 10px; |
|
|
} |
|
|
|
|
|
.editor::-webkit-scrollbar-thumb:hover { |
|
|
background: var(--gradient-hover); |
|
|
} |
|
|
|
|
|
|
|
|
.fade-in { |
|
|
animation: fadeIn 0.4s ease-out; |
|
|
} |
|
|
|
|
|
@keyframes fadeIn { |
|
|
from { |
|
|
opacity: 0; |
|
|
transform: translateY(20px) scale(0.98); |
|
|
} |
|
|
|
|
|
to { |
|
|
opacity: 1; |
|
|
transform: translateY(0) scale(1); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.password-input:focus, |
|
|
.editor:focus { |
|
|
outline: none; |
|
|
} |
|
|
|
|
|
@keyframes slideUp { |
|
|
from { |
|
|
opacity: 0; |
|
|
transform: translateY(30px); |
|
|
} |
|
|
|
|
|
to { |
|
|
opacity: 1; |
|
|
transform: translateY(0); |
|
|
} |
|
|
} |
|
|
|
|
|
@keyframes paperUnfold { |
|
|
from { |
|
|
opacity: 0; |
|
|
transform: scale(0.95) rotateX(5deg); |
|
|
} |
|
|
|
|
|
to { |
|
|
opacity: 1; |
|
|
transform: scale(1) rotateX(0deg); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.editor-container::after { |
|
|
content: ''; |
|
|
position: absolute; |
|
|
top: 0; |
|
|
left: 0; |
|
|
right: 0; |
|
|
bottom: 0; |
|
|
background: var(--paper-texture); |
|
|
opacity: 0.1; |
|
|
pointer-events: none; |
|
|
z-index: 1; |
|
|
border-radius: var(--radius); |
|
|
} |
|
|
|
|
|
|
|
|
@media (max-width: 600px) { |
|
|
.header { |
|
|
padding: 12px 16px; |
|
|
gap: 8px; |
|
|
} |
|
|
|
|
|
.app-title { |
|
|
font-size: 20px; |
|
|
} |
|
|
|
|
|
.header-right { |
|
|
flex-wrap: nowrap; |
|
|
gap: 8px; |
|
|
} |
|
|
|
|
|
.save-status { |
|
|
font-size: 10px; |
|
|
padding: 6px 12px; |
|
|
min-width: 70px; |
|
|
letter-spacing: 0; |
|
|
} |
|
|
|
|
|
.word-count { |
|
|
font-size: 11px; |
|
|
padding: 4px 10px; |
|
|
} |
|
|
|
|
|
.editor-container { |
|
|
margin: 12px; |
|
|
} |
|
|
|
|
|
.editor { |
|
|
padding: 20px; |
|
|
font-size: 16px; |
|
|
} |
|
|
|
|
|
.card-front, |
|
|
.card-back { |
|
|
padding: 28px 20px; |
|
|
} |
|
|
|
|
|
.card-front h1 { |
|
|
font-size: 32px; |
|
|
} |
|
|
|
|
|
.login-subtitle { |
|
|
font-size: 14px; |
|
|
margin-bottom: 24px; |
|
|
} |
|
|
|
|
|
.info-list { |
|
|
margin-bottom: 16px; |
|
|
} |
|
|
|
|
|
.info-list li { |
|
|
font-size: 13px; |
|
|
padding: 6px 0; |
|
|
padding-left: 20px; |
|
|
} |
|
|
|
|
|
.info-title { |
|
|
font-size: 16px; |
|
|
margin-bottom: 12px; |
|
|
} |
|
|
|
|
|
.info-link { |
|
|
font-size: 11px; |
|
|
padding: 8px 12px; |
|
|
} |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
|
|
|
<body> |
|
|
<div id="loginScreen" class="login-screen"> |
|
|
<div id="cardContainer" class="card-container"> |
|
|
<div class="card-inner"> |
|
|
|
|
|
<div class="card-front"> |
|
|
<div class="info-icon" onclick="flipCard()" title="Learn more">i</div> |
|
|
<h1>Paper</h1> |
|
|
<p class="login-subtitle">Perfect for temporary notes and secure sharing. Deleted after two days. |
|
|
</p> |
|
|
|
|
|
<div class="input-group"> |
|
|
<input type="password" id="passwordInput" class="password-input" |
|
|
placeholder="min-8-char password" minlength="8" maxlength="100"> |
|
|
</div> |
|
|
|
|
|
<button onclick="login()" class="enter-btn">Enter</button> |
|
|
<div id="loginError" class="error"></div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="card-back"> |
|
|
<div class="back-icon" onclick="flipCard()" title="Back to login">X</div> |
|
|
<h3 class="info-title">About Paper ✨</h3> |
|
|
<ul class="info-list"> |
|
|
<li><strong>What is it?</strong> A secure notepad for temporary notes you can access from |
|
|
anywhere.</li> |
|
|
<li><strong>Encryption:</strong> Notes encrypted client-side with your password. Never sent to |
|
|
server.</li> |
|
|
<li><strong>Open Source:</strong> 100% open. Deployed on Hugging Face, Open for everyone to see. |
|
|
</li> |
|
|
<li><strong>Zero Access:</strong> Even the developer cannot read your notes. Only encrypted |
|
|
blobs stored.</li> |
|
|
<li><strong>Auto-Delete:</strong> Notes automatically deleted after 2 days of inactivity.</li> |
|
|
<li><strong>Pro tip:</strong> Use a strong password! If someone guesses it... well, that's on |
|
|
you 😅</li> |
|
|
</ul> |
|
|
<a href="https://github.com/jebin2/Paper" target="_blank" class="info-link"> |
|
|
🔗 View source on GitHub |
|
|
</a> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div id="editorScreen" class="editor-screen"> |
|
|
<div class="header"> |
|
|
<div class="header-left"> |
|
|
<div class="app-title" onclick="goToLogin()" style="cursor: pointer;" title="Go to login">Paper</div> |
|
|
</div> |
|
|
<div class="header-right"> |
|
|
<div id="wordCount" class="word-count">0 words</div> |
|
|
<div id="saveStatus" class="save-status">Ready</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="editor-container"> |
|
|
<textarea id="editor" class="editor" placeholder="Start typing..."></textarea> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
let currentPassword = ''; |
|
|
let currentSalt = null; |
|
|
let fileHash = ''; |
|
|
let saveTimeout = null; |
|
|
let isWorking = false; |
|
|
|
|
|
const PBKDF2_ITERATIONS = 250000; |
|
|
|
|
|
|
|
|
function flipCard() { |
|
|
const container = document.getElementById('cardContainer'); |
|
|
container.classList.toggle('flipped'); |
|
|
} |
|
|
|
|
|
|
|
|
function updateWordCount() { |
|
|
const text = document.getElementById('editor').value; |
|
|
const words = text.trim() ? text.trim().split(/\s+/).length : 0; |
|
|
const chars = text.length; |
|
|
document.getElementById('wordCount').textContent = `${words} words, ${chars} chars`; |
|
|
} |
|
|
|
|
|
|
|
|
async function generateFilenameHash(password) { |
|
|
const encoder = new TextEncoder(); |
|
|
const data = encoder.encode(password); |
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data); |
|
|
const hashArray = Array.from(new Uint8Array(hashBuffer)); |
|
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16); |
|
|
} |
|
|
|
|
|
async function deriveKey(password, salt) { |
|
|
const encoder = new TextEncoder(); |
|
|
const keyMaterial = await crypto.subtle.importKey( |
|
|
'raw', |
|
|
encoder.encode(password), |
|
|
{ name: 'PBKDF2' }, |
|
|
false, |
|
|
['deriveKey'] |
|
|
); |
|
|
return crypto.subtle.deriveKey( |
|
|
{ |
|
|
name: 'PBKDF2', |
|
|
salt: salt, |
|
|
iterations: PBKDF2_ITERATIONS, |
|
|
hash: 'SHA-256' |
|
|
}, |
|
|
keyMaterial, |
|
|
{ name: 'AES-GCM', length: 256 }, |
|
|
true, |
|
|
['encrypt', 'decrypt'] |
|
|
); |
|
|
} |
|
|
|
|
|
async function encrypt(text, key) { |
|
|
const encoder = new TextEncoder(); |
|
|
const data = encoder.encode(text); |
|
|
const iv = crypto.getRandomValues(new Uint8Array(12)); |
|
|
|
|
|
const encryptedContent = await crypto.subtle.encrypt( |
|
|
{ name: 'AES-GCM', iv: iv }, |
|
|
key, |
|
|
data |
|
|
); |
|
|
|
|
|
const combined = new Uint8Array(iv.length + encryptedContent.byteLength); |
|
|
combined.set(iv); |
|
|
combined.set(new Uint8Array(encryptedContent), iv.length); |
|
|
|
|
|
return btoa(String.fromCharCode.apply(null, combined)); |
|
|
} |
|
|
|
|
|
async function decrypt(encryptedBase64, key) { |
|
|
try { |
|
|
const combined = Uint8Array.from(atob(encryptedBase64), c => c.charCodeAt(0)); |
|
|
const iv = combined.slice(0, 12); |
|
|
const encryptedContent = combined.slice(12); |
|
|
|
|
|
const decrypted = await crypto.subtle.decrypt( |
|
|
{ name: 'AES-GCM', iv: iv }, |
|
|
key, |
|
|
encryptedContent |
|
|
); |
|
|
|
|
|
return new TextDecoder().decode(decrypted); |
|
|
} catch (error) { |
|
|
console.error('Decryption failed:', error); |
|
|
throw new Error('Decryption failed. Check password.'); |
|
|
} |
|
|
} |
|
|
|
|
|
function base64ToUint8Array(base64) { |
|
|
const binaryString = atob(base64); |
|
|
const len = binaryString.length; |
|
|
const bytes = new Uint8Array(len); |
|
|
for (let i = 0; i < len; i++) { |
|
|
bytes[i] = binaryString.charCodeAt(i); |
|
|
} |
|
|
return bytes; |
|
|
} |
|
|
|
|
|
|
|
|
document.getElementById('passwordInput').focus(); |
|
|
document.getElementById('passwordInput').addEventListener('keypress', e => { |
|
|
if (e.key === 'Enter') login(); |
|
|
}); |
|
|
|
|
|
|
|
|
function fixIOSViewport() { |
|
|
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; |
|
|
if (isIOS) { |
|
|
const vh = window.innerHeight * 0.01; |
|
|
document.documentElement.style.setProperty('--vh', `${vh}px`); |
|
|
|
|
|
window.addEventListener('resize', () => { |
|
|
const vh = window.innerHeight * 0.01; |
|
|
document.documentElement.style.setProperty('--vh', `${vh}px`); |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
fixIOSViewport(); |
|
|
|
|
|
async function login() { |
|
|
if (isWorking) return; |
|
|
isWorking = true; |
|
|
|
|
|
const password = document.getElementById('passwordInput').value; |
|
|
const errorDiv = document.getElementById('loginError'); |
|
|
const enterBtn = document.querySelector('.enter-btn'); |
|
|
|
|
|
errorDiv.textContent = ''; |
|
|
if (password.length < 8) { |
|
|
errorDiv.textContent = 'Password must be at least 8 characters'; |
|
|
isWorking = false; |
|
|
return; |
|
|
} |
|
|
|
|
|
enterBtn.textContent = 'Loading...'; |
|
|
enterBtn.disabled = true; |
|
|
|
|
|
currentPassword = password; |
|
|
fileHash = await generateFilenameHash(password); |
|
|
|
|
|
try { |
|
|
const response = await fetch('/api/load', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ hash: fileHash }) |
|
|
}); |
|
|
|
|
|
const data = await response.json(); |
|
|
|
|
|
if (!response.ok) { |
|
|
throw new Error(data.error || 'Login failed'); |
|
|
} |
|
|
|
|
|
currentSalt = base64ToUint8Array(data.salt); |
|
|
|
|
|
let content = ''; |
|
|
if (data.content) { |
|
|
const key = await deriveKey(currentPassword, currentSalt); |
|
|
content = await decrypt(data.content, key); |
|
|
} |
|
|
|
|
|
document.getElementById('editor').value = content; |
|
|
document.getElementById('loginScreen').style.display = 'none'; |
|
|
document.getElementById('editorScreen').style.display = 'flex'; |
|
|
setupAutoSave(); |
|
|
updateSaveStatus('Ready'); |
|
|
updateWordCount(); |
|
|
|
|
|
} catch (error) { |
|
|
errorDiv.textContent = error.message.includes('Decryption') ? 'Invalid password' : 'Connection error'; |
|
|
} finally { |
|
|
isWorking = false; |
|
|
enterBtn.textContent = 'Enter'; |
|
|
enterBtn.disabled = false; |
|
|
} |
|
|
} |
|
|
|
|
|
function setupAutoSave() { |
|
|
const editor = document.getElementById('editor'); |
|
|
|
|
|
editor.addEventListener('input', () => { |
|
|
clearTimeout(saveTimeout); |
|
|
updateSaveStatus('Typing...'); |
|
|
updateWordCount(); |
|
|
|
|
|
saveTimeout = setTimeout(saveContent, 1500); |
|
|
}); |
|
|
} |
|
|
|
|
|
async function saveContent() { |
|
|
if (isWorking) return; |
|
|
isWorking = true; |
|
|
|
|
|
updateSaveStatus('Saving...'); |
|
|
|
|
|
const content = document.getElementById('editor').value; |
|
|
|
|
|
try { |
|
|
const key = await deriveKey(currentPassword, currentSalt); |
|
|
const encryptedContent = await encrypt(content, key); |
|
|
|
|
|
const response = await fetch('/api/save', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ |
|
|
hash: fileHash, |
|
|
content: encryptedContent |
|
|
}) |
|
|
}); |
|
|
|
|
|
if (response.ok) { |
|
|
updateSaveStatus('Saved'); |
|
|
} else { |
|
|
const data = await response.json(); |
|
|
updateSaveStatus(`Error: ${data.error || 'Save failed'}`); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Save failed:', error); |
|
|
updateSaveStatus('Save failed'); |
|
|
} finally { |
|
|
isWorking = false; |
|
|
} |
|
|
} |
|
|
|
|
|
function goToLogin() { |
|
|
const statusDiv = document.getElementById('saveStatus'); |
|
|
|
|
|
if (!statusDiv.className.includes('saved')) { |
|
|
if (!confirm('You have unsaved changes. Are you sure you want to leave?')) { |
|
|
return; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
currentPassword = ''; |
|
|
currentSalt = null; |
|
|
fileHash = ''; |
|
|
clearTimeout(saveTimeout); |
|
|
|
|
|
|
|
|
document.getElementById('editor').value = ''; |
|
|
document.getElementById('passwordInput').value = ''; |
|
|
document.getElementById('loginError').textContent = ''; |
|
|
document.getElementById('editorScreen').style.display = 'none'; |
|
|
document.getElementById('loginScreen').style.display = 'flex'; |
|
|
document.getElementById('passwordInput').focus(); |
|
|
} |
|
|
|
|
|
function updateSaveStatus(status) { |
|
|
const statusDiv = document.getElementById('saveStatus'); |
|
|
statusDiv.textContent = status; |
|
|
|
|
|
statusDiv.className = 'save-status'; |
|
|
if (status.includes('Saving') || status.includes('Typing')) { |
|
|
statusDiv.className += ' saving'; |
|
|
} else if (status === 'Saved') { |
|
|
statusDiv.className += ' saved'; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
window.addEventListener('beforeunload', function (e) { |
|
|
const statusDiv = document.getElementById('saveStatus'); |
|
|
|
|
|
if (!statusDiv.className.includes('saved')) { |
|
|
e.preventDefault(); |
|
|
e.returnValue = ''; |
|
|
} |
|
|
}); |
|
|
</script> |
|
|
</body> |
|
|
|
|
|
</html> |