Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note_extension
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note_extension
Commits
25b29d27
Commit
25b29d27
authored
Apr 29, 2026
by
Vũ Hoàng Anh
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: complete redesign — shadcn/ui dark mode, Canifa branding, Memos auth
parent
ba521e44
Changes
15
Hide whitespace changes
Inline
Side-by-side
Showing
15 changed files
with
2011 additions
and
2233 deletions
+2011
-2233
manifest.json
extension/dist/.vite/manifest.json
+9
-9
popup-dYtSf2jU.css
extension/dist/assets/popup-dYtSf2jU.css
+0
-1
manifest.json
extension/dist/manifest.json
+5
-5
service-worker-loader.js
extension/dist/service-worker-loader.js
+1
-1
popup.html
extension/dist/src/popup/popup.html
+16
-19
manifest.json
extension/manifest.json
+3
-3
service-worker.ts
extension/src/background/service-worker.ts
+134
-156
NoteForm.tsx
extension/src/components/NoteForm.tsx
+170
-185
Settings.tsx
extension/src/components/Settings.tsx
+128
-182
TagSelector.tsx
extension/src/components/TagSelector.tsx
+129
-138
content-script.ts
extension/src/content/content-script.ts
+276
-340
popup.css
extension/src/popup/popup.css
+717
-618
popup.html
extension/src/popup/popup.html
+23
-26
popup.tsx
extension/src/popup/popup.tsx
+182
-258
api-client.ts
extension/src/shared/api-client.ts
+218
-292
No files found.
extension/dist/.vite/manifest.json
View file @
25b29d27
{
{
"../../../@crx/manifest"
:
{
"../../../@crx/manifest"
:
{
"file"
:
"assets/crx-manifest.js-
SqlU4S0k
.js"
,
"file"
:
"assets/crx-manifest.js-
kDmztkte
.js"
,
"name"
:
"crx-manifest.js"
,
"name"
:
"crx-manifest.js"
,
"src"
:
"../../../@crx/manifest"
,
"src"
:
"../../../@crx/manifest"
,
"isEntry"
:
true
"isEntry"
:
true
},
},
"_api-client-
Y5oDKlCr
.js"
:
{
"_api-client-
CzjXLdoC
.js"
:
{
"file"
:
"assets/api-client-
Y5oDKlCr
.js"
,
"file"
:
"assets/api-client-
CzjXLdoC
.js"
,
"name"
:
"api-client"
"name"
:
"api-client"
},
},
"src/background/service-worker.ts"
:
{
"src/background/service-worker.ts"
:
{
"file"
:
"assets/service-worker.ts-
DvMHIkFe
.js"
,
"file"
:
"assets/service-worker.ts-
C6gHU6BA
.js"
,
"name"
:
"service-worker.ts"
,
"name"
:
"service-worker.ts"
,
"src"
:
"src/background/service-worker.ts"
,
"src"
:
"src/background/service-worker.ts"
,
"isEntry"
:
true
,
"isEntry"
:
true
,
"imports"
:
[
"imports"
:
[
"_api-client-
Y5oDKlCr
.js"
"_api-client-
CzjXLdoC
.js"
]
]
},
},
"src/content/content-script.ts"
:
{
"src/content/content-script.ts"
:
{
"file"
:
"assets/content-script.ts-
BWL85FVS
.js"
,
"file"
:
"assets/content-script.ts-
CTdB63jU
.js"
,
"name"
:
"content-script.ts"
,
"name"
:
"content-script.ts"
,
"src"
:
"src/content/content-script.ts"
,
"src"
:
"src/content/content-script.ts"
,
"isEntry"
:
true
"isEntry"
:
true
},
},
"src/popup/popup.html"
:
{
"src/popup/popup.html"
:
{
"file"
:
"assets/popup-
DmXuB8QF
.js"
,
"file"
:
"assets/popup-
CSzsyzxK
.js"
,
"name"
:
"popup"
,
"name"
:
"popup"
,
"src"
:
"src/popup/popup.html"
,
"src"
:
"src/popup/popup.html"
,
"isEntry"
:
true
,
"isEntry"
:
true
,
"imports"
:
[
"imports"
:
[
"_api-client-
Y5oDKlCr
.js"
"_api-client-
CzjXLdoC
.js"
],
],
"css"
:
[
"css"
:
[
"assets/popup-
dYtSf2jU
.css"
"assets/popup-
C6DDov6Y
.css"
]
]
}
}
}
}
\ No newline at end of file
extension/dist/assets/popup-dYtSf2jU.css
deleted
100644 → 0
View file @
ba521e44
:root
{
--background
:
oklch
(
.95
.015
75
);
--foreground
:
oklch
(
.25
.02
65
);
--card
:
oklch
(
.98
.008
80
);
--card-foreground
:
oklch
(
.22
.015
68
);
--popover
:
oklch
(
.98
.008
80
);
--popover-foreground
:
oklch
(
.25
.02
65
);
--primary
:
oklch
(
.45
.08
45
);
--primary-foreground
:
oklch
(
.98
.008
80
);
--secondary
:
oklch
(
.92
.025
70
);
--secondary-foreground
:
oklch
(
.35
.03
60
);
--muted
:
oklch
(
.9
.025
75
);
--muted-foreground
:
oklch
(
.5
.02
68
);
--accent
:
oklch
(
.88
.035
55
);
--accent-foreground
:
oklch
(
.25
.02
65
);
--destructive
:
oklch
(
.48
.15
25
);
--border
:
oklch
(
.88
.018
72
);
--input
:
oklch
(
.8
.03
75
);
--ring
:
oklch
(
.45
.08
45
);
--radius
:
12px
;
--success
:
oklch
(
.6
.15
145
);
--warning
:
oklch
(
.7
.12
75
);
--shadow-sm
:
0
1px
2px
oklch
(
0
0
0
/
.04
);
--shadow-md
:
0
4px
12px
oklch
(
0
0
0
/
.06
);
--shadow-lg
:
0
8px
24px
oklch
(
0
0
0
/
.08
);
--font-sans
:
"Inter"
,
ui-sans-serif
,
system-ui
,
-apple-system
,
BlinkMacSystemFont
,
"Segoe UI"
,
Roboto
,
sans-serif
}
[
data-theme
=
dark
]
{
--background
:
oklch
(
.16
.008
60
);
--foreground
:
oklch
(
.9
.012
75
);
--card
:
oklch
(
.2
.01
62
);
--card-foreground
:
oklch
(
.9
.012
75
);
--popover
:
oklch
(
.2
.01
62
);
--popover-foreground
:
oklch
(
.88
.01
72
);
--primary
:
oklch
(
.65
.1
45
);
--primary-foreground
:
oklch
(
.15
.008
60
);
--secondary
:
oklch
(
.26
.012
65
);
--secondary-foreground
:
oklch
(
.85
.01
72
);
--muted
:
oklch
(
.23
.01
62
);
--muted-foreground
:
oklch
(
.6
.015
70
);
--accent
:
oklch
(
.28
.015
55
);
--accent-foreground
:
oklch
(
.82
.012
68
);
--destructive
:
oklch
(
.55
.1
25
);
--border
:
oklch
(
.3
.012
62
);
--input
:
oklch
(
.35
.015
65
);
--ring
:
oklch
(
.65
.1
45
);
--success
:
oklch
(
.65
.15
145
);
--warning
:
oklch
(
.75
.12
75
);
--shadow-sm
:
0
1px
2px
oklch
(
0
0
0
/
.15
);
--shadow-md
:
0
4px
12px
oklch
(
0
0
0
/
.2
);
--shadow-lg
:
0
8px
24px
oklch
(
0
0
0
/
.25
)}
*
{
box-sizing
:
border-box
;
margin
:
0
;
padding
:
0
}
body
{
font-family
:
var
(
--font-sans
);
background
:
var
(
--background
);
color
:
var
(
--foreground
);
font-size
:
13px
;
line-height
:
1.5
;
-webkit-font-smoothing
:
antialiased
;
-moz-osx-font-smoothing
:
grayscale
;
width
:
380px
;
min-height
:
200px
;
overflow-x
:
hidden
}
@keyframes
fadeIn
{
0
%
{
opacity
:
0
;
transform
:
translateY
(
6px
)}
to
{
opacity
:
1
;
transform
:
translateY
(
0
)}}
@keyframes
slideUp
{
0
%
{
opacity
:
0
;
transform
:
translateY
(
12px
)}
to
{
opacity
:
1
;
transform
:
translateY
(
0
)}}
@keyframes
pulse
{
0
%,
to
{
opacity
:
1
}
50
%
{
opacity
:
.5
}}
@keyframes
spin
{
0
%
{
transform
:
rotate
(
0
)}
to
{
transform
:
rotate
(
360deg
)}}
@keyframes
glow
{
0
%,
to
{
box-shadow
:
0
0
4px
#3a97424
d
}
50
%
{
box-shadow
:
0
0
10px
#3a974280
}}
@keyframes
successPop
{
0
%
{
transform
:
scale
(
.9
);
opacity
:
0
}
50
%
{
transform
:
scale
(
1.02
)}
to
{
transform
:
scale
(
1
);
opacity
:
1
}}
.popup-container
{
display
:
flex
;
flex-direction
:
column
;
min-height
:
100%
;
animation
:
fadeIn
.2s
ease-out
}
.header
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
padding
:
14px
18px
;
background
:
var
(
--card
);
border-bottom
:
1px
solid
var
(
--border
);
-webkit-backdrop-filter
:
blur
(
10px
);
backdrop-filter
:
blur
(
10px
)}
.header-brand
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
font-weight
:
700
;
font-size
:
15px
;
color
:
var
(
--foreground
);
letter-spacing
:
-.01em
}
.header-brand-icon
{
font-size
:
20px
;
filter
:
drop-shadow
(
0
1px
2px
oklch
(
0
0
0
/
.1
))}
.header-status
{
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
font-size
:
11px
;
font-weight
:
500
;
color
:
var
(
--muted-foreground
)}
.status-dot
{
width
:
8px
;
height
:
8px
;
border-radius
:
50%
;
flex-shrink
:
0
}
.status-dot.status-connected
{
background
:
var
(
--success
);
animation
:
glow
2s
ease-in-out
infinite
}
.status-dot.status-offline
{
background
:
var
(
--destructive
)}
.status-text
{
font-size
:
11px
}
.content
{
padding
:
16px
18px
;
animation
:
fadeIn
.3s
ease-out
}
.note-form
{
display
:
flex
;
flex-direction
:
column
;
animation
:
slideUp
.25s
ease-out
}
.note-textarea
{
display
:
block
;
width
:
100%
;
min-height
:
100px
;
max-height
:
200px
;
resize
:
vertical
;
border
:
1.5px
solid
var
(
--border
);
border-radius
:
var
(
--radius
);
background
:
var
(
--background
);
color
:
var
(
--foreground
);
padding
:
12px
14px
;
font-size
:
14px
;
font-family
:
inherit
;
line-height
:
1.6
;
transition
:
border-color
.2s
ease
,
box-shadow
.2s
ease
}
.note-textarea
::placeholder
{
color
:
var
(
--muted-foreground
);
font-style
:
italic
}
.note-textarea
:focus
{
outline
:
none
;
border-color
:
var
(
--ring
);
box-shadow
:
0
0
0
3px
#7a462e1
a
}
.source-info
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
font-size
:
11px
;
color
:
var
(
--muted-foreground
);
padding
:
8px
12px
;
background
:
var
(
--muted
);
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
margin-top
:
10px
;
border
:
1px
solid
var
(
--border
)}
.label
{
display
:
block
;
font-size
:
11px
;
font-weight
:
600
;
margin-bottom
:
8px
;
color
:
var
(
--muted-foreground
);
text-transform
:
uppercase
;
letter-spacing
:
.05em
}
.tag-chips
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
6px
;
margin-top
:
6px
}
.tag-chip
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
4px
;
padding
:
5px
12px
;
border-radius
:
100px
;
font-size
:
12px
;
font-weight
:
500
;
cursor
:
pointer
;
transition
:
all
.2s
cubic-bezier
(
.4
,
0
,
.2
,
1
);
border
:
1.5px
solid
var
(
--border
);
background
:
var
(
--card
);
color
:
var
(
--muted-foreground
);
font-family
:
inherit
;
-webkit-user-select
:
none
;
user-select
:
none
}
.tag-chip
:hover
{
border-color
:
var
(
--primary
);
color
:
var
(
--primary
);
background
:
#7a462e0
f
;
transform
:
translateY
(
-1px
);
box-shadow
:
var
(
--shadow-sm
)}
.tag-chip.selected
{
background
:
var
(
--primary
);
color
:
var
(
--primary-foreground
);
border-color
:
var
(
--primary
);
box-shadow
:
0
2px
6px
#7a462e33
}
.tag-chip.selected
:hover
{
filter
:
brightness
(
1.1
);
background
:
var
(
--primary
);
color
:
var
(
--primary-foreground
);
transform
:
translateY
(
-1px
)}
.tag-input-inline
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
4px
;
padding
:
5px
12px
;
border-radius
:
100px
;
font-size
:
12px
;
border
:
1.5px
dashed
var
(
--border
);
background
:
transparent
;
color
:
var
(
--muted-foreground
);
cursor
:
pointer
;
transition
:
all
.2s
ease
;
font-family
:
inherit
}
.tag-input-inline
:hover
{
border-color
:
var
(
--primary
);
color
:
var
(
--primary
)}
.tag-input-field
{
border
:
none
;
outline
:
none
;
background
:
transparent
;
font-size
:
12px
;
font-family
:
inherit
;
color
:
var
(
--foreground
);
width
:
80px
}
.form-group
{
margin-bottom
:
14px
}
.select
{
display
:
block
;
width
:
100%
;
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
border
:
1.5px
solid
var
(
--border
);
background
:
var
(
--background
);
color
:
var
(
--foreground
);
padding
:
10px
36px
10px
14px
;
font-size
:
13px
;
font-family
:
inherit
;
cursor
:
pointer
;
appearance
:
none
;
background-image
:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E")
;
background-repeat
:
no-repeat
;
background-position
:
right
12px
center
;
transition
:
border-color
.2s
ease
,
box-shadow
.2s
ease
}
.select
:focus
{
outline
:
none
;
border-color
:
var
(
--ring
);
box-shadow
:
0
0
0
3px
#7a462e1
a
}
.visibility-selector
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
5px
;
padding
:
6px
14px
;
border-radius
:
100px
;
font-size
:
12px
;
font-weight
:
500
;
border
:
1.5px
solid
var
(
--border
);
background
:
var
(
--card
);
color
:
var
(
--muted-foreground
);
cursor
:
pointer
;
transition
:
all
.2s
ease
;
font-family
:
inherit
}
.visibility-selector
:hover
{
border-color
:
var
(
--primary
);
color
:
var
(
--primary
)}
.btn
{
display
:
inline-flex
;
align-items
:
center
;
justify-content
:
center
;
gap
:
6px
;
white-space
:
nowrap
;
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
font-size
:
13px
;
font-weight
:
600
;
font-family
:
inherit
;
cursor
:
pointer
;
border
:
none
;
padding
:
9px
18px
;
transition
:
all
.2s
cubic-bezier
(
.4
,
0
,
.2
,
1
)}
.btn
:active
{
transform
:
scale
(
.96
)}
.btn-primary
{
background
:
linear-gradient
(
135deg
,
var
(
--primary
),
oklch
(
.5
.1
50
));
color
:
var
(
--primary-foreground
);
box-shadow
:
0
2px
8px
#7a462e40
}
.btn-primary
:hover
{
filter
:
brightness
(
1.08
);
box-shadow
:
0
4px
14px
#7a462e59
;
transform
:
translateY
(
-1px
)}
.btn-secondary
{
background
:
var
(
--secondary
);
color
:
var
(
--secondary-foreground
);
border
:
1.5px
solid
var
(
--border
)}
.btn-secondary
:hover
{
background
:
var
(
--muted
);
transform
:
translateY
(
-1px
)}
.btn-ghost
{
background
:
transparent
;
color
:
var
(
--muted-foreground
);
padding
:
6px
12px
}
.btn-ghost
:hover
{
background
:
var
(
--muted
);
color
:
var
(
--foreground
)}
.btn-sm
{
padding
:
6px
14px
;
font-size
:
12px
;
border-radius
:
calc
(
var
(
--radius
)
-
4px
)}
.btn
:disabled
{
opacity
:
.5
;
cursor
:
not-allowed
;
transform
:
none
!important
;
filter
:
none
!important
}
.action-bar
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
padding
:
12px
18px
14px
;
border-top
:
1px
solid
var
(
--border
);
background
:
var
(
--card
)}
.action-bar-left
{
display
:
flex
;
align-items
:
center
;
gap
:
6px
}
.action-bar-right
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
}
.badge
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
4px
;
padding
:
4px
10px
;
border-radius
:
100px
;
font-size
:
12px
;
font-weight
:
600
}
.badge-success
{
background
:
#3a97421
f
;
color
:
var
(
--success
);
border
:
1px
solid
oklch
(
.6
.15
145
/
.2
);
animation
:
successPop
.3s
ease-out
}
.error
{
padding
:
10px
14px
;
margin-top
:
10px
;
background
:
#b9464214
;
color
:
var
(
--destructive
);
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
font-size
:
12px
;
border
:
1px
solid
oklch
(
.55
.15
25
/
.15
);
animation
:
fadeIn
.2s
ease-out
}
.success
{
padding
:
10px
14px
;
margin-bottom
:
12px
;
background
:
#3a974214
;
color
:
var
(
--success
);
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
font-size
:
12px
;
border
:
1px
solid
oklch
(
.6
.15
145
/
.15
)}
.spinner
{
display
:
inline-block
;
width
:
14px
;
height
:
14px
;
border
:
2px
solid
var
(
--border
);
border-top-color
:
var
(
--primary
);
border-radius
:
50%
;
animation
:
spin
.6s
linear
infinite
;
vertical-align
:
middle
}
::-webkit-scrollbar
{
width
:
5px
}
::-webkit-scrollbar-track
{
background
:
transparent
}
::-webkit-scrollbar-thumb
{
background
:
var
(
--border
);
border-radius
:
100px
}
::-webkit-scrollbar-thumb:hover
{
background
:
var
(
--muted-foreground
)}
*,*
:before
,*
:after
{
transition-property
:
background-color
,
color
,
border-color
,
box-shadow
,
opacity
;
transition-duration
:
.2s
;
transition-timing-function
:
ease
}
.spinner
,
.spinner
*,[
class
*=
animate-
]
{
transition
:
none
!important
}
extension/dist/manifest.json
View file @
25b29d27
{
{
"manifest_version"
:
3
,
"manifest_version"
:
3
,
"name"
:
"C
uCu
Note"
,
"name"
:
"C
anifa
Note"
,
"version"
:
"1.
0
.0"
,
"version"
:
"1.
1
.0"
,
"description"
:
"
Quick note-taking extension - Highlight text and save to CuCu Note
"
,
"description"
:
"
Ghi chú nhanh từ web — Bôi đen, nhấn Space, lưu tự động
"
,
"permissions"
:
[
"permissions"
:
[
"activeTab"
,
"activeTab"
,
"storage"
,
"storage"
,
...
@@ -20,7 +20,7 @@
...
@@ -20,7 +20,7 @@
"content_scripts"
:
[
"content_scripts"
:
[
{
{
"js"
:
[
"js"
:
[
"assets/content-script.ts-
BWL85FVS
.js"
"assets/content-script.ts-
CTdB63jU
.js"
],
],
"matches"
:
[
"matches"
:
[
"<all_urls>"
"<all_urls>"
...
@@ -48,7 +48,7 @@
...
@@ -48,7 +48,7 @@
"<all_urls>"
"<all_urls>"
],
],
"resources"
:
[
"resources"
:
[
"assets/content-script.ts-
BWL85FVS
.js"
"assets/content-script.ts-
CTdB63jU
.js"
],
],
"use_dynamic_url"
:
false
"use_dynamic_url"
:
false
}
}
...
...
extension/dist/service-worker-loader.js
View file @
25b29d27
import
'./assets/service-worker.ts-
DvMHIkFe
.js'
;
import
'./assets/service-worker.ts-
C6gHU6BA
.js'
;
extension/dist/src/popup/popup.html
View file @
25b29d27
<!DOCTYPE html>
<!DOCTYPE html>
<html
lang=
"en"
>
<html
lang=
"en"
class=
"dark"
>
<head>
<head>
<meta
charset=
"UTF-8"
>
<meta
charset=
"UTF-8"
>
<meta
name=
"viewport"
content=
"width=device-width, initial-scale=1.0"
>
<meta
name=
"viewport"
content=
"width=device-width, initial-scale=1.0"
>
<title>
CuCu Note
</title>
<title>
Canifa Note
</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=Inter:wght@400;500;600;700&display=swap"
rel=
"stylesheet"
>
<style>
<style>
body
{
body
{
margin
:
0
;
margin
:
0
;
padding
:
0
;
padding
:
0
;
width
:
380px
;
width
:
380px
;
min-height
:
42
0px
;
min-height
:
38
0px
;
max-height
:
560px
;
max-height
:
560px
;
overflow-y
:
auto
;
overflow-y
:
auto
;
}
}
</style>
</style>
<script
type=
"module"
crossorigin
src=
"/assets/popup-
DmXuB8QF
.js"
></script>
<script
type=
"module"
crossorigin
src=
"/assets/popup-
CSzsyzxK
.js"
></script>
<link
rel=
"modulepreload"
crossorigin
href=
"/assets/api-client-
Y5oDKlCr
.js"
>
<link
rel=
"modulepreload"
crossorigin
href=
"/assets/api-client-
CzjXLdoC
.js"
>
<link
rel=
"stylesheet"
crossorigin
href=
"/assets/popup-
dYtSf2jU
.css"
>
<link
rel=
"stylesheet"
crossorigin
href=
"/assets/popup-
C6DDov6Y
.css"
>
</head>
</head>
<body>
<body>
<div
id=
"popup-root"
></div>
<div
id=
"popup-root"
></div>
</body>
</body>
</html>
</html>
extension/manifest.json
View file @
25b29d27
{
{
"manifest_version"
:
3
,
"manifest_version"
:
3
,
"name"
:
"C
uCu
Note"
,
"name"
:
"C
anifa
Note"
,
"version"
:
"1.
0
.0"
,
"version"
:
"1.
1
.0"
,
"description"
:
"
Quick note-taking extension - Highlight text and save to CuCu Note
"
,
"description"
:
"
Ghi chú nhanh từ web — Bôi đen, nhấn Space, lưu tự động
"
,
"permissions"
:
[
"permissions"
:
[
"activeTab"
,
"activeTab"
,
"storage"
,
"storage"
,
...
...
extension/src/background/service-worker.ts
View file @
25b29d27
/**
/**
* Background Service Worker
* Background Service Worker — Canifa Note
* Nhiệm vụ:
*
* 1. Handle SYNC_AUTH từ content script (auto-sync Clerk token)
* 1. Handle SYNC_AUTH from content script (auto-sync Memos token)
* 2. Handle SAVE_NOTE từ content script (gọi API save memo)
* 2. Handle SAVE_NOTE from content script (call API save memo)
* 3. Handle REFRESH_TOKEN từ popup (refresh Clerk JWT trước khi gọi API)
* 3. Handle REFRESH_TOKEN from popup
*/
*/
import
{
createMemo
}
from
'../shared/api-client'
;
import
{
createMemo
}
from
'../shared/api-client'
;
/**
/**
* Grab fresh Clerk token from ANY OpenNotion tab
* Grab fresh Memos token from any Canifa Note tab
* Scans all open tabs, finds OpenNotion, injects script to get token
*/
*/
async
function
grabFreshToken
():
Promise
<
string
|
null
>
{
async
function
grabFreshToken
():
Promise
<
string
|
null
>
{
try
{
try
{
const
allTabs
=
await
chrome
.
tabs
.
query
({});
const
allTabs
=
await
chrome
.
tabs
.
query
({});
const
candidates
=
allTabs
.
filter
((
t
)
=>
{
const
candidates
=
allTabs
.
filter
((
t
)
=>
{
const
url
=
t
.
url
||
''
;
const
url
=
t
.
url
||
''
;
return
(
return
(
url
.
includes
(
'172.16.2.210'
)
||
url
.
includes
(
'172.16.2.210'
)
||
url
.
includes
(
'localhost:5230'
)
||
url
.
includes
(
'localhost:3001'
)
||
url
.
includes
(
'cucunote'
)
||
url
.
includes
(
'opennotion'
)
||
url
.
includes
(
'canifa'
)
url
.
includes
(
'cucunote'
)
);
);
});
});
for
(
const
tab
of
candidates
)
{
for
(
const
tab
of
candidates
)
{
if
(
!
tab
.
id
)
continue
;
if
(
!
tab
.
id
)
continue
;
try
{
try
{
const
results
=
await
chrome
.
scripting
.
executeScript
({
const
results
=
await
chrome
.
scripting
.
executeScript
({
target
:
{
tabId
:
tab
.
id
},
target
:
{
tabId
:
tab
.
id
},
world
:
'MAIN'
,
world
:
'MAIN'
,
func
:
()
=>
{
func
:
async
()
=>
{
try
{
try
{
// 1. Memos access-token cookie
// 1. Try Memos cookie
const
match
=
document
.
cookie
.
match
(
/
(?:
^|;
)
memos
\.
access-token=
([^
;
]
+
)
/
);
const
match
=
document
.
cookie
.
match
(
/
(?:
^|;
)
memos
\.
access-token=
([^
;
]
+
)
/
);
if
(
match
&&
match
[
1
])
return
match
[
1
];
if
(
match
&&
match
[
1
])
return
match
[
1
];
// 2. localStorage
// 2. Try localStorage
for
(
let
i
=
0
;
i
<
localStorage
.
length
;
i
++
)
{
for
(
let
i
=
0
;
i
<
localStorage
.
length
;
i
++
)
{
const
key
=
localStorage
.
key
(
i
);
const
key
=
localStorage
.
key
(
i
);
if
(
key
&&
(
key
.
includes
(
'access_token'
)
||
key
.
includes
(
'token'
)))
{
if
(
key
&&
(
key
.
includes
(
'token'
)
||
key
.
includes
(
'session'
)))
{
const
val
=
localStorage
.
getItem
(
key
);
const
val
=
localStorage
.
getItem
(
key
);
if
(
val
&&
val
.
length
>
20
&&
!
val
.
startsWith
(
'{'
))
return
val
;
if
(
val
&&
typeof
val
===
'string'
&&
val
.
length
>
50
&&
!
val
.
startsWith
(
'{'
))
{
}
return
val
;
}
}
}
catch
{
/* ignore */
}
}
return
null
;
}
},
});
// 3. Try Clerk
const
token
=
results
[
0
]?.
result
;
const
clerk
=
(
window
as
any
).
Clerk
;
if
(
token
)
{
if
(
clerk
?.
session
?.
getToken
)
{
await
chrome
.
storage
.
local
.
set
({
return
await
clerk
.
session
.
getToken
();
memosAccessToken
:
token
,
}
tokenSyncedAt
:
Date
.
now
(),
}
catch
{
/* ignore */
}
});
return
null
;
console
.
log
(
'[Canifa Note] ✅ Token synced from tab'
,
tab
.
id
);
},
return
token
;
});
}
const
token
=
results
[
0
]?.
result
;
}
catch
{
if
(
token
)
{
// Try next tab
await
chrome
.
storage
.
local
.
set
({
}
clerkSessionToken
:
token
,
}
clerkTokenSyncedAt
:
Date
.
now
(),
}
catch
{
});
// Tab scanning failed
console
.
log
(
'[CuCu BG] ✅ Fresh token from tab'
,
tab
.
id
,
`(
${
token
.
length
}
chars)`
);
}
return
token
;
return
null
;
}
}
}
catch
{
// This tab didn't work, try next
chrome
.
runtime
.
onMessage
.
addListener
((
message
,
_sender
,
sendResponse
)
=>
{
}
// ============ AUTO-SYNC AUTH TOKEN ============
}
if
(
message
.
type
===
'SYNC_AUTH'
)
{
}
catch
{
const
token
=
message
.
data
?.
memosAccessToken
||
message
.
data
?.
clerkSessionToken
;
// Tab scanning failed
if
(
token
)
{
}
chrome
.
storage
.
local
.
set
({
return
null
;
memosAccessToken
:
token
,
}
tokenSyncedAt
:
Date
.
now
(),
});
chrome
.
runtime
.
onMessage
.
addListener
((
message
,
_sender
,
sendResponse
)
=>
{
console
.
log
(
'[Canifa Note] ✅ Token auto-synced from content script'
);
// ============ AUTO-SYNC AUTH TOKEN ============
}
if
(
message
.
type
===
'SYNC_AUTH'
)
{
sendResponse
({
success
:
true
});
const
{
clerkSessionToken
}
=
message
.
data
||
{};
return
false
;
if
(
clerkSessionToken
)
{
}
chrome
.
storage
.
local
.
set
({
clerkSessionToken
,
// ============ REFRESH TOKEN ============
clerkTokenSyncedAt
:
Date
.
now
(),
if
(
message
.
type
===
'REFRESH_TOKEN'
)
{
});
grabFreshToken
()
console
.
log
(
'[CuCu BG] ✅ Clerk token auto-synced from content script'
);
.
then
((
token
)
=>
{
}
if
(
token
)
{
sendResponse
({
success
:
true
});
sendResponse
({
success
:
true
,
token
});
return
false
;
// synchronous response
}
else
{
}
sendResponse
({
success
:
false
,
reason
:
'no_canifa_note_tab'
});
}
// ============ REFRESH TOKEN (popup requests fresh token) ============
})
if
(
message
.
type
===
'REFRESH_TOKEN'
)
{
.
catch
((
err
)
=>
{
// Scan ALL OpenNotion tabs to get a fresh Clerk JWT
sendResponse
({
success
:
false
,
reason
:
err
?.
message
});
grabFreshToken
()
});
.
then
((
token
)
=>
{
return
true
;
if
(
token
)
{
}
sendResponse
({
success
:
true
,
token
});
}
else
{
// ============ SHOW NOTE FORM ============
sendResponse
({
success
:
false
,
reason
:
'no_opennotion_tab'
});
if
(
message
.
type
===
'SHOW_NOTE_FORM'
)
{
}
chrome
.
action
.
openPopup
();
})
chrome
.
storage
.
local
.
set
({
pendingNote
:
message
.
data
});
.
catch
((
err
)
=>
{
sendResponse
({
success
:
true
});
console
.
log
(
'[CuCu BG] ❌ Token refresh failed:'
,
err
?.
message
);
}
sendResponse
({
success
:
false
,
reason
:
err
?.
message
});
});
// ============ SAVE NOTE ============
return
true
;
// keep channel open for async
else
if
(
message
.
type
===
'SAVE_NOTE'
)
{
}
const
{
text
,
url
,
title
}
=
message
.
data
;
// ============ SHOW NOTE FORM ============
const
domain
=
new
URL
(
url
).
hostname
.
replace
(
'www.'
,
''
);
if
(
message
.
type
===
'SHOW_NOTE_FORM'
)
{
const
tagList
=
[
domain
,
'web-highlight'
];
// Mở popup với note form
const
contentWithSource
=
`
${
text
}
\n\n---\nSource: [
${
title
}
](
${
url
}
)`
;
chrome
.
action
.
openPopup
();
const
memoData
=
{
// Lưu data vào storage để popup có thể lấy
content
:
contentWithSource
,
chrome
.
storage
.
local
.
set
({
tags
:
tagList
,
pendingNote
:
message
.
data
,
visibility
:
'PRIVATE'
,
});
};
sendResponse
({
success
:
true
});
createMemo
(
memoData
)
}
.
then
((
memo
)
=>
{
sendResponse
({
success
:
true
,
memo
});
// ============ SAVE NOTE ============
})
else
if
(
message
.
type
===
'SAVE_NOTE'
)
{
.
catch
((
error
)
=>
{
// Auto save note ngay lập tức
sendResponse
({
success
:
false
,
error
:
error
.
message
});
const
{
text
,
url
,
title
}
=
message
.
data
;
});
// Parse tags từ URL
return
true
;
const
domain
=
new
URL
(
url
).
hostname
.
replace
(
'www.'
,
''
);
}
const
tagList
=
[
domain
,
'web-highlight'
];
return
true
;
// Add source info vào content
});
const
contentWithSource
=
`
${
text
}
\n\n---\nSource: [
${
title
}
](
${
url
}
)`
;
// Gọi API để save (có gửi kèm Clerk token trong Authorization header)
const
memoData
=
{
content
:
contentWithSource
,
tags
:
tagList
,
visibility
:
'PRIVATE'
,
};
createMemo
(
memoData
)
.
then
((
memo
)
=>
{
sendResponse
({
success
:
true
,
memo
});
})
.
catch
((
error
)
=>
{
sendResponse
({
success
:
false
,
error
:
error
.
message
});
});
return
true
;
// Keep channel open for async
}
return
true
;
// Keep channel open for async response
});
extension/src/components/NoteForm.tsx
View file @
25b29d27
/**
/**
* NoteForm Component
* NoteForm Component — shadcn/ui styled
*
*
* The main note-taking form. Accepts a getToken prop from Clerk
* Clean, minimal note form. Always visible.
* to get fresh JWTs for API calls. Does NOT close the popup after save.
* Uses Memos access token for API calls.
*/
*/
import
{
useState
}
from
'react'
;
import
{
useState
}
from
'react'
;
import
{
createMemoWithToken
}
from
'../shared/api-client'
;
import
{
createMemoWithToken
}
from
'../shared/api-client'
;
import
{
TagSelector
}
from
'./TagSelector'
;
import
{
TagSelector
}
from
'./TagSelector'
;
import
{
WorkspaceSelector
}
from
'./WorkspaceSelector'
;
interface
NoteFormProps
{
interface
NoteFormProps
{
initialText
?:
string
;
initialText
?:
string
;
initialUrl
?:
string
;
initialUrl
?:
string
;
initialTitle
?:
string
;
initialTitle
?:
string
;
getToken
:
()
=>
Promise
<
string
|
null
>
;
getToken
:
()
=>
Promise
<
string
|
null
>
;
onSave
?:
()
=>
void
;
onSave
?:
()
=>
void
;
onCancel
?:
()
=>
void
;
onCancel
?:
()
=>
void
;
}
}
export
function
NoteForm
({
export
function
NoteForm
({
initialText
=
''
,
initialText
=
''
,
initialUrl
=
''
,
initialUrl
=
''
,
initialTitle
=
''
,
initialTitle
=
''
,
getToken
,
getToken
,
onSave
,
onSave
,
onCancel
,
onCancel
,
}:
NoteFormProps
)
{
}:
NoteFormProps
)
{
const
[
text
,
setText
]
=
useState
(
initialText
);
const
[
text
,
setText
]
=
useState
(
initialText
);
const
[
tags
,
setTags
]
=
useState
<
string
[]
>
([]);
const
[
tags
,
setTags
]
=
useState
<
string
[]
>
([]);
const
[
visibility
,
setVisibility
]
=
useState
<
'PRIVATE'
|
'PUBLIC'
>
(
'PRIVATE'
);
const
[
workspace
,
setWorkspace
]
=
useState
(
''
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
visibility
,
setVisibility
]
=
useState
<
'PRIVATE'
|
'PUBLIC'
>
(
'PRIVATE'
);
const
[
saved
,
setSaved
]
=
useState
(
false
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
<
string
|
null
>
(
null
);
const
[
saved
,
setSaved
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
<
string
|
null
>
(
null
);
const
handleSave
=
async
()
=>
{
if
(
!
text
.
trim
())
{
const
handleSave
=
async
()
=>
{
setError
(
'Chưa nhập nội dung ghi chú'
);
if
(
!
text
.
trim
())
{
return
;
setError
(
'Note cannot be empty'
);
}
return
;
}
setLoading
(
true
);
setError
(
null
);
setLoading
(
true
);
setSaved
(
false
);
setError
(
null
);
setSaved
(
false
);
try
{
const
token
=
await
getToken
();
try
{
if
(
!
token
)
{
// Get FRESH token from Clerk
setError
(
'Chưa đồng bộ. Mở Canifa Note trên trình duyệt và đăng nhập.'
);
const
token
=
await
getToken
();
setLoading
(
false
);
if
(
!
token
)
{
return
;
setError
(
'Not authenticated. Please sign in again.'
);
}
setLoading
(
false
);
return
;
let
content
=
text
;
}
if
(
initialUrl
&&
initialUrl
!==
'about:blank'
)
{
content
+=
`\n\n---\nSource: [
${
initialTitle
||
initialUrl
}
](
${
initialUrl
}
)`
;
// Build content with source info
}
let
content
=
text
;
if
(
initialUrl
&&
initialUrl
!==
'about:blank'
)
{
await
createMemoWithToken
(
token
,
{
content
+=
`\n\n---\nSource: [
${
initialTitle
||
initialUrl
}
](
${
initialUrl
}
)`
;
content
,
}
tags
,
visibility
,
// Call API with fresh token
});
await
createMemoWithToken
(
token
,
{
content
,
setLoading
(
false
);
tags
,
setSaved
(
true
);
visibility
,
setText
(
''
);
});
setTags
([]);
setError
(
null
);
// Success! Show message but KEEP popup open
setTimeout
(()
=>
setSaved
(
false
),
3000
);
setLoading
(
false
);
if
(
onSave
)
onSave
();
setSaved
(
true
);
}
catch
(
err
)
{
setError
(
err
instanceof
Error
?
err
.
message
:
'Lưu thất bại'
);
// Clear form for next note
setLoading
(
false
);
setText
(
''
);
}
setTags
([]);
};
setError
(
null
);
return
(
// Auto-hide success after 3s
<
div
className=
"note-form"
>
setTimeout
(()
=>
setSaved
(
false
),
3000
);
<
div
className=
"content"
>
{
/* Success */
}
if
(
onSave
)
onSave
();
{
saved
&&
(
}
catch
(
err
)
{
<
div
className=
"success"
style=
{
{
textAlign
:
'center'
,
animation
:
'fadeIn 0.2s ease'
}
}
>
setError
(
err
instanceof
Error
?
err
.
message
:
'Failed to save note'
);
✓ Đã lưu vào Canifa Note
setLoading
(
false
);
</
div
>
}
)
}
};
{
/* Textarea */
}
return
(
<
textarea
<
div
className=
"note-form"
>
className=
"note-textarea"
<
div
className=
"content"
>
placeholder=
"Ghi chú..."
{
/* Success Banner */
}
value=
{
text
}
{
saved
&&
(
onChange=
{
(
e
)
=>
setText
(
e
.
target
.
value
)
}
<
div
className=
"badge badge-success"
style=
{
{
rows=
{
4
}
textAlign
:
'center'
,
autoFocus
padding
:
'10px'
,
/>
fontSize
:
'14px'
,
marginBottom
:
'8px'
,
{
/* Source */
}
animation
:
'fadeIn 0.3s ease'
{
initialUrl
&&
initialUrl
!==
'about:blank'
&&
(
}
}
>
<
div
className=
"source-info"
>
✅ Saved to CuCu Note!
<
svg
className=
"source-icon"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2"
strokeLinecap=
"round"
strokeLinejoin=
"round"
>
</
div
>
<
path
d=
"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"
/>
)
}
<
path
d=
"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"
/>
</
svg
>
{
/* Text Area */
}
<
span
style=
{
{
overflow
:
'hidden'
,
textOverflow
:
'ellipsis'
,
whiteSpace
:
'nowrap'
}
}
>
<
textarea
{
initialTitle
||
initialUrl
}
className=
"note-textarea"
</
span
>
placeholder=
"Any thoughts..."
</
div
>
value=
{
text
}
)
}
onChange=
{
(
e
)
=>
setText
(
e
.
target
.
value
)
}
rows=
{
4
}
{
/* Tags */
}
autoFocus
<
div
style=
{
{
marginTop
:
'10px'
}
}
>
/>
<
TagSelector
selectedTags=
{
tags
}
onChange=
{
setTags
}
/>
</
div
>
{
/* Source Info */
}
{
initialUrl
&&
initialUrl
!==
'about:blank'
&&
(
{
/* Error */
}
<
div
className=
"source-info"
style=
{
{
{
error
&&
<
div
className=
"error"
>
{
error
}
</
div
>
}
fontSize
:
'12px'
,
</
div
>
color
:
'var(--text-secondary)'
,
padding
:
'6px 10px'
,
{
/* Action Bar */
}
backgroundColor
:
'var(--surface)'
,
<
div
className=
"action-bar"
>
borderRadius
:
'8px'
,
<
div
className=
"action-bar-left"
>
marginTop
:
'6px'
,
<
button
overflow
:
'hidden'
,
className=
"visibility-selector"
textOverflow
:
'ellipsis'
,
onClick=
{
()
=>
setVisibility
(
visibility
===
'PRIVATE'
?
'PUBLIC'
:
'PRIVATE'
)
}
whiteSpace
:
'nowrap'
,
title=
{
`Toggle: ${visibility}`
}
}
}
>
>
🔗
{
initialTitle
||
initialUrl
}
{
visibility
===
'PRIVATE'
?
(
</
div
>
<
svg
width=
"12"
height=
"12"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2"
strokeLinecap=
"round"
strokeLinejoin=
"round"
>
)
}
<
rect
x=
"3"
y=
"11"
width=
"18"
height=
"11"
rx=
"2"
ry=
"2"
/>
<
path
d=
"M7 11V7a5 5 0 0 1 10 0v4"
/>
{
/* Tag Selector */
}
</
svg
>
<
div
style=
{
{
marginTop
:
'12px'
}
}
>
)
:
(
<
TagSelector
selectedTags=
{
tags
}
onChange=
{
setTags
}
/>
<
svg
width=
"12"
height=
"12"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2"
strokeLinecap=
"round"
strokeLinejoin=
"round"
>
</
div
>
<
circle
cx=
"12"
cy=
"12"
r=
"10"
/>
<
line
x1=
"2"
y1=
"12"
x2=
"22"
y2=
"12"
/>
{
/* Workspace Selector */
}
<
path
d=
"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
/>
<
WorkspaceSelector
value=
{
workspace
}
onChange=
{
setWorkspace
}
/>
</
svg
>
)
}
{
/* Error */
}
{
visibility
===
'PRIVATE'
?
'Private'
:
'Public'
}
{
error
&&
<
div
className=
"error"
>
{
error
}
</
div
>
}
</
button
>
</
div
>
</
div
>
<
div
className=
"action-bar-right"
>
{
/* Action Bar */
}
<
button
<
div
className=
"action-bar"
>
className=
"btn btn-ghost btn-sm"
<
div
className=
"action-bar-left"
>
onClick=
{
onCancel
||
(()
=>
window
.
close
())
}
<
button
disabled=
{
loading
}
className=
"visibility-selector"
>
onClick=
{
()
=>
Cancel
setVisibility
(
visibility
===
'PRIVATE'
?
'PUBLIC'
:
'PRIVATE'
)
</
button
>
}
<
button
title=
{
`Click to toggle: ${visibility}`
}
className=
"btn btn-primary btn-sm"
>
onClick=
{
handleSave
}
{
visibility
===
'PRIVATE'
?
'🔒'
:
'🌐'
}{
' '
}
disabled=
{
loading
||
!
text
.
trim
()
}
{
visibility
===
'PRIVATE'
?
'Private'
:
'Public'
}
>
</
button
>
{
saved
?
(
</
div
>
'✓ Saved'
<
div
className=
"action-bar-right"
>
)
:
loading
?
(
<
button
<>
className=
"btn btn-ghost btn-sm"
<
span
className=
"spinner"
style=
{
{
width
:
12
,
height
:
12
}
}
/>
Saving...
onClick=
{
onCancel
||
(()
=>
window
.
close
())
}
</>
disabled=
{
loading
}
)
:
(
>
'Save'
Cancel
)
}
</
button
>
</
button
>
<
button
</
div
>
className=
"btn btn-primary btn-sm"
</
div
>
onClick=
{
handleSave
}
</
div
>
disabled=
{
loading
||
!
text
.
trim
()
}
);
>
}
{
saved
?
(
'✅ Saved!'
)
:
loading
?
(
<>
<
span
className=
"spinner"
/>
Saving...
</>
)
:
(
'Save'
)
}
</
button
>
</
div
>
</
div
>
</
div
>
);
}
extension/src/components/Settings.tsx
View file @
25b29d27
/**
/**
* Settings Component
* Settings Component — shadcn/ui styled
* Server URL config, auth sync, connection test
*/
*/
import
{
useState
,
useEffect
}
from
'react'
;
import
{
useState
,
useEffect
}
from
'react'
;
import
{
import
{
getApiBaseUrl
,
getApiBaseUrl
,
setApiBaseUrl
,
setApiBaseUrl
,
getAuthStatus
,
getAuthStatus
,
testConnection
,
syncClerkTokenFromPage
,
}
from
'../shared/api-client'
;
testConnection
,
}
from
'../shared/api-client'
;
export
function
Settings
()
{
const
[
serverUrl
,
setServerUrl
]
=
useState
(
''
);
export
function
Settings
()
{
const
[
authStatus
,
setAuthStatus
]
=
useState
<
{
isConnected
:
boolean
;
tokenLength
:
number
}
>
({
const
[
serverUrl
,
setServerUrl
]
=
useState
(
''
);
isConnected
:
false
,
const
[
authStatus
,
setAuthStatus
]
=
useState
<
{
isConnected
:
boolean
;
tokenLength
:
number
}
>
({
tokenLength
:
0
,
isConnected
:
false
,
});
tokenLength
:
0
,
const
[
testResult
,
setTestResult
]
=
useState
<
{
ok
:
boolean
;
message
:
string
}
|
null
>
(
null
);
});
const
[
testing
,
setTesting
]
=
useState
(
false
);
const
[
testResult
,
setTestResult
]
=
useState
<
{
ok
:
boolean
;
message
:
string
}
|
null
>
(
null
);
const
[
saved
,
setSaved
]
=
useState
(
false
);
const
[
syncing
,
setSyncing
]
=
useState
(
false
);
const
[
testing
,
setTesting
]
=
useState
(
false
);
useEffect
(()
=>
{
const
[
saved
,
setSaved
]
=
useState
(
false
);
loadSettings
();
},
[]);
useEffect
(()
=>
{
loadSettings
();
const
loadSettings
=
async
()
=>
{
},
[]);
const
url
=
await
getApiBaseUrl
();
setServerUrl
(
url
);
const
loadSettings
=
async
()
=>
{
const
status
=
await
getAuthStatus
();
const
url
=
await
getApiBaseUrl
();
setAuthStatus
(
status
);
setServerUrl
(
url
);
};
const
status
=
await
getAuthStatus
();
setAuthStatus
(
status
);
const
handleSaveUrl
=
async
()
=>
{
};
const
cleaned
=
serverUrl
.
trim
().
replace
(
/
\/
+$/
,
''
);
await
setApiBaseUrl
(
cleaned
);
const
handleSaveUrl
=
async
()
=>
{
setServerUrl
(
cleaned
);
const
cleaned
=
serverUrl
.
trim
().
replace
(
/
\/
+$/
,
''
);
// remove trailing slashes
setSaved
(
true
);
await
setApiBaseUrl
(
cleaned
);
setTestResult
(
null
);
setServerUrl
(
cleaned
);
setTimeout
(()
=>
setSaved
(
false
),
2000
);
setSaved
(
true
);
};
setTestResult
(
null
);
setTimeout
(()
=>
setSaved
(
false
),
2000
);
const
handleTestConnection
=
async
()
=>
{
};
setTesting
(
true
);
setTestResult
(
null
);
const
handleSyncAuth
=
async
()
=>
{
const
result
=
await
testConnection
();
setSyncing
(
true
);
setTestResult
(
result
);
try
{
setTesting
(
false
);
const
token
=
await
syncClerkTokenFromPage
();
};
if
(
token
)
{
setAuthStatus
({
isConnected
:
true
,
tokenLength
:
token
.
length
});
return
(
}
else
{
<
div
className=
"content"
>
setAuthStatus
({
isConnected
:
false
,
tokenLength
:
0
});
{
/* Server */
}
}
<
div
className=
"settings-section"
>
}
catch
{
<
div
className=
"settings-title"
>
Server
</
div
>
setAuthStatus
({
isConnected
:
false
,
tokenLength
:
0
});
<
div
className=
"form-group"
>
}
<
label
className=
"label"
>
API URL
</
label
>
setSyncing
(
false
);
<
div
className=
"settings-input-group"
>
};
<
input
type=
"url"
const
handleTestConnection
=
async
()
=>
{
className=
"input"
setTesting
(
true
);
value=
{
serverUrl
}
setTestResult
(
null
);
onChange=
{
(
e
)
=>
{
const
result
=
await
testConnection
();
setServerUrl
(
e
.
target
.
value
);
setTestResult
(
result
);
setSaved
(
false
);
setTesting
(
false
);
setTestResult
(
null
);
};
}
}
placeholder=
"http://172.16.2.210:5230"
return
(
/>
<
div
className=
"content"
>
<
button
className=
"btn btn-primary btn-sm"
onClick=
{
handleSaveUrl
}
>
{
/* Server URL */
}
{
saved
?
'✓'
:
'Save'
}
<
div
className=
"settings-section"
>
</
button
>
<
div
className=
"settings-title"
>
🌐 Server
</
div
>
</
div
>
<
div
className=
"label-hint"
>
Backend server URL (without /api/v1)
</
div
>
<
div
className=
"form-group"
>
</
div
>
<
label
className=
"label"
>
API URL
</
label
>
<
div
className=
"settings-input-group"
>
<
div
style=
{
{
marginTop
:
'6px'
}
}
>
<
input
<
button
type=
"url"
className=
"btn btn-secondary btn-sm btn-full"
className=
"input"
onClick=
{
handleTestConnection
}
value=
{
serverUrl
}
disabled=
{
testing
}
onChange=
{
(
e
)
=>
{
>
setServerUrl
(
e
.
target
.
value
);
{
testing
?
(
setSaved
(
false
);
<>
setTestResult
(
null
);
<
span
className=
"spinner"
style=
{
{
width
:
12
,
height
:
12
}
}
/>
Testing...
}
}
</>
placeholder=
"https://your-domain.com"
)
:
(
/>
'Test Connection'
<
button
className=
"btn btn-primary btn-sm"
onClick=
{
handleSaveUrl
}
>
)
}
{
saved
?
'✓'
:
'Save'
}
</
button
>
</
button
>
</
div
>
</
div
>
<
div
className=
"label-hint"
>
{
testResult
&&
(
Backend server URL (without /api/v1)
<
div
className=
{
testResult
.
ok
?
'success'
:
'error'
}
style=
{
{
marginTop
:
'6px'
}
}
>
</
div
>
{
testResult
.
message
}
</
div
>
</
div
>
)
}
<
div
style=
{
{
marginTop
:
'8px'
}
}
>
</
div
>
<
button
className=
"btn btn-secondary btn-sm btn-full"
{
/* Auth Status */
}
onClick=
{
handleTestConnection
}
<
div
className=
"settings-section"
>
disabled=
{
testing
}
<
div
className=
"settings-title"
>
Authentication
</
div
>
>
<
div
className=
"settings-item"
>
{
testing
?
(
<
div
>
<>
<
div
className=
"settings-item-label"
>
Memos Access Token
</
div
>
<
span
className=
"spinner"
/>
Testing...
<
div
className=
"settings-item-desc"
>
Auto-synced from Canifa Note tab
</
div
>
</>
</
div
>
)
:
(
<
span
className=
{
`badge ${authStatus.isConnected ? 'badge-success' : 'badge-error'}`
}
>
'🔌 Test Connection'
<
span
className=
{
`status-dot ${authStatus.isConnected ? 'connected' : 'disconnected'}`
}
/>
)
}
{
authStatus
.
isConnected
?
'Active'
:
'Inactive'
}
</
button
>
</
span
>
</
div
>
</
div
>
</
div
>
{
testResult
&&
(
<
div
{
/* About */
}
className=
{
testResult
.
ok
?
'success'
:
'error'
}
<
div
className=
"settings-section"
>
style=
{
{
marginTop
:
'8px'
}
}
<
div
className=
"settings-title"
>
About
</
div
>
>
<
div
style=
{
{
fontSize
:
'12px'
,
color
:
'var(--muted-foreground)'
,
lineHeight
:
'1.6'
}
}
>
{
testResult
.
message
}
<
strong
>
Canifa Note
</
strong
>
v1.0.0
</
div
>
<
br
/>
)
}
Ghi chú nhanh từ bất kỳ trang web nào.
</
div
>
<
br
/>
Bôi đen → Nhấn Space → Lưu tự động
{
/* Auth */
}
</
div
>
<
div
className=
"settings-section"
>
</
div
>
<
div
className=
"settings-title"
>
🔑 Authentication
</
div
>
</
div
>
);
<
div
className=
"settings-item"
>
}
<
div
>
<
div
className=
"settings-item-label"
>
Clerk Session
</
div
>
<
div
className=
"settings-item-desc"
>
Sync auth from your OpenNotion app
</
div
>
</
div
>
<
span
className=
{
`badge ${authStatus.isConnected ? 'badge-success' : 'badge-error'}`
}
>
<
span
className=
{
`status-dot ${authStatus.isConnected ? 'connected' : 'disconnected'}`
}
/>
{
authStatus
.
isConnected
?
'Active'
:
'Inactive'
}
</
span
>
</
div
>
<
div
style=
{
{
marginTop
:
'8px'
}
}
>
<
button
className=
"btn btn-secondary btn-sm btn-full"
onClick=
{
handleSyncAuth
}
disabled=
{
syncing
}
>
{
syncing
?
(
<>
<
span
className=
"spinner"
/>
Syncing...
</>
)
:
(
'🔄 Sync Auth from Current Page'
)
}
</
button
>
<
div
className=
"label-hint"
style=
{
{
marginTop
:
'6px'
}
}
>
Open your OpenNotion app first, then click sync
</
div
>
</
div
>
</
div
>
{
/* About */
}
<
div
className=
"settings-section"
>
<
div
className=
"settings-title"
>
ℹ️ About
</
div
>
<
div
style=
{
{
fontSize
:
'12px'
,
color
:
'var(--muted-foreground)'
,
lineHeight
:
'1.6'
,
}
}
>
<
strong
>
CuCu Note
</
strong
>
v1.0.0
<
br
/>
Quick note-taking from any web page.
<
br
/>
Highlight text → Press Space → Auto save!
</
div
>
</
div
>
</
div
>
);
}
extension/src/components/TagSelector.tsx
View file @
25b29d27
/**
/**
* Tag Selector Component
* Tag Selector — shadcn/ui badge chips
* Chip-based tag selector with server-fetched tags + inline new tag input
*/
*/
import
{
useState
,
useEffect
,
useRef
}
from
'react'
;
import
{
useState
,
useEffect
,
useRef
}
from
'react'
;
import
{
fetchTags
,
getRecentTags
,
saveRecentTags
}
from
'../shared/api-client'
;
import
{
fetchTags
,
getRecentTags
,
saveRecentTags
}
from
'../shared/api-client'
;
interface
TagSelectorProps
{
interface
TagSelectorProps
{
selectedTags
:
string
[];
selectedTags
:
string
[];
onChange
:
(
tags
:
string
[])
=>
void
;
onChange
:
(
tags
:
string
[])
=>
void
;
}
}
export
function
TagSelector
({
selectedTags
,
onChange
}:
TagSelectorProps
)
{
export
function
TagSelector
({
selectedTags
,
onChange
}:
TagSelectorProps
)
{
const
[
availableTags
,
setAvailableTags
]
=
useState
<
string
[]
>
([]);
const
[
availableTags
,
setAvailableTags
]
=
useState
<
string
[]
>
([]);
const
[
isAdding
,
setIsAdding
]
=
useState
(
false
);
const
[
isAdding
,
setIsAdding
]
=
useState
(
false
);
const
[
newTag
,
setNewTag
]
=
useState
(
''
);
const
[
newTag
,
setNewTag
]
=
useState
(
''
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
inputRef
=
useRef
<
HTMLInputElement
>
(
null
);
const
inputRef
=
useRef
<
HTMLInputElement
>
(
null
);
useEffect
(()
=>
{
useEffect
(()
=>
{
loadTags
();
loadTags
();
},
[]);
},
[]);
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
isAdding
&&
inputRef
.
current
)
{
if
(
isAdding
&&
inputRef
.
current
)
{
inputRef
.
current
.
focus
();
inputRef
.
current
.
focus
();
}
}
},
[
isAdding
]);
},
[
isAdding
]);
const
loadTags
=
async
()
=>
{
const
loadTags
=
async
()
=>
{
setLoading
(
true
);
setLoading
(
true
);
try
{
try
{
const
[
serverTags
,
recentTags
]
=
await
Promise
.
all
([
const
[
serverTags
,
recentTags
]
=
await
Promise
.
all
([
fetchTags
(),
fetchTags
(),
getRecentTags
(),
getRecentTags
(),
]);
]);
const
merged
=
[...
new
Set
([...
recentTags
,
...
serverTags
])];
// Merge: recent first, then server tags
setAvailableTags
(
merged
);
const
merged
=
[...
new
Set
([...
recentTags
,
...
serverTags
])];
}
catch
{
setAvailableTags
(
merged
);
// Fallback
}
catch
{
}
// Fallback to empty
setLoading
(
false
);
}
};
setLoading
(
false
);
};
const
toggleTag
=
(
tag
:
string
)
=>
{
if
(
selectedTags
.
includes
(
tag
))
{
const
toggleTag
=
(
tag
:
string
)
=>
{
onChange
(
selectedTags
.
filter
((
t
)
=>
t
!==
tag
));
if
(
selectedTags
.
includes
(
tag
))
{
}
else
{
onChange
(
selectedTags
.
filter
((
t
)
=>
t
!==
tag
));
onChange
([...
selectedTags
,
tag
]);
}
else
{
saveRecentTags
([
tag
,
...
selectedTags
]);
onChange
([...
selectedTags
,
tag
]);
}
// Save to recent
};
saveRecentTags
([
tag
,
...
selectedTags
]);
}
const
addNewTag
=
()
=>
{
};
const
cleaned
=
newTag
.
trim
().
replace
(
/^#/
,
''
);
if
(
cleaned
&&
!
selectedTags
.
includes
(
cleaned
))
{
const
addNewTag
=
()
=>
{
onChange
([...
selectedTags
,
cleaned
]);
const
cleaned
=
newTag
.
trim
().
replace
(
/^#/
,
''
);
if
(
!
availableTags
.
includes
(
cleaned
))
{
if
(
cleaned
&&
!
selectedTags
.
includes
(
cleaned
))
{
setAvailableTags
([
cleaned
,
...
availableTags
]);
onChange
([...
selectedTags
,
cleaned
]);
}
if
(
!
availableTags
.
includes
(
cleaned
))
{
saveRecentTags
([
cleaned
,
...
selectedTags
]);
setAvailableTags
([
cleaned
,
...
availableTags
]);
}
}
setNewTag
(
''
);
saveRecentTags
([
cleaned
,
...
selectedTags
]);
setIsAdding
(
false
);
}
};
setNewTag
(
''
);
setIsAdding
(
false
);
const
handleKeyDown
=
(
e
:
React
.
KeyboardEvent
)
=>
{
};
if
(
e
.
key
===
'Enter'
)
{
e
.
preventDefault
();
const
handleKeyDown
=
(
e
:
React
.
KeyboardEvent
)
=>
{
addNewTag
();
if
(
e
.
key
===
'Enter'
)
{
}
else
if
(
e
.
key
===
'Escape'
)
{
e
.
preventDefault
();
setNewTag
(
''
);
addNewTag
();
setIsAdding
(
false
);
}
else
if
(
e
.
key
===
'Escape'
)
{
}
setNewTag
(
''
);
};
setIsAdding
(
false
);
}
const
displayTags
=
availableTags
.
slice
(
0
,
10
);
};
return
(
// Show max 12 tags to keep UI compact
<
div
className=
"form-group"
>
const
displayTags
=
availableTags
.
slice
(
0
,
12
);
<
label
className=
"label"
>
Tags
</
label
>
<
div
className=
"tag-chips"
>
return
(
{
loading
?
(
<
div
className=
"form-group"
>
<
span
style=
{
{
fontSize
:
'11px'
,
color
:
'var(--muted-foreground)'
}
}
>
<
label
className=
"label"
>
Tags
</
label
>
Loading...
<
div
className=
"tag-chips"
>
</
span
>
{
loading
?
(
)
:
(
<
span
style=
{
{
fontSize
:
'11px'
,
color
:
'var(--muted-foreground)'
}
}
>
<>
Loading tags...
{
displayTags
.
map
((
tag
)
=>
(
</
span
>
<
button
)
:
(
key=
{
tag
}
<>
type=
"button"
{
displayTags
.
map
((
tag
)
=>
(
className=
{
`tag-chip ${selectedTags.includes(tag) ? 'selected' : ''}`
}
<
button
onClick=
{
()
=>
toggleTag
(
tag
)
}
key=
{
tag
}
>
type=
"button"
<
span
className=
"tag-chip-icon"
>
#
</
span
>
className=
{
`tag-chip ${selectedTags.includes(tag) ? 'selected' : ''}`
}
{
tag
}
onClick=
{
()
=>
toggleTag
(
tag
)
}
</
button
>
>
))
}
<
span
className=
"tag-chip-icon"
>
#
</
span
>
{
tag
}
{
isAdding
?
(
</
button
>
<
div
className=
"tag-input-inline"
>
))
}
<
span
className=
"tag-chip-icon"
>
#
</
span
>
<
input
{
isAdding
?
(
ref=
{
inputRef
}
<
div
className=
"tag-input-inline"
>
type=
"text"
<
span
className=
"tag-chip-icon"
>
#
</
span
>
className=
"tag-input-field"
<
input
value=
{
newTag
}
ref=
{
inputRef
}
onChange=
{
(
e
)
=>
setNewTag
(
e
.
target
.
value
)
}
type=
"text"
onKeyDown=
{
handleKeyDown
}
className=
"tag-input-field"
onBlur=
{
addNewTag
}
value=
{
newTag
}
placeholder=
"tag"
onChange=
{
(
e
)
=>
setNewTag
(
e
.
target
.
value
)
}
/>
onKeyDown=
{
handleKeyDown
}
</
div
>
onBlur=
{
addNewTag
}
)
:
(
placeholder=
"new tag"
<
button
/>
type=
"button"
</
div
>
className=
"tag-input-inline"
)
:
(
onClick=
{
()
=>
setIsAdding
(
true
)
}
<
button
>
type=
"button"
+ Add
className=
"tag-input-inline"
</
button
>
onClick=
{
()
=>
setIsAdding
(
true
)
}
)
}
>
</>
+ Add
)
}
</
button
>
</
div
>
)
}
</
div
>
</>
);
)
}
}
</
div
>
{
selectedTags
.
length
>
0
&&
(
<
div
className=
"label-hint"
>
{
selectedTags
.
length
}
tag
{
selectedTags
.
length
>
1
?
's'
:
''
}
selected
</
div
>
)
}
</
div
>
);
}
extension/src/content/content-script.ts
View file @
25b29d27
/**
/**
* Content Script - Chạy trên mọi web page
* Content Script — Canifa Note
* Nhiệm vụ:
*
* 1. Auto-sync Clerk auth token khi đang ở trang OpenNotion
* 1. Auto-sync Memos access token when on Canifa Note pages
* 2. Detect text selection → Space key → Auto save → Toast notification
* 2. Detect text selection → Space/Enter → Auto save with toast
*/
*/
let
selectedText
=
''
;
let
selectedText
=
''
;
let
selectedUrl
=
''
;
let
selectedUrl
=
''
;
let
selectedTitle
=
''
;
let
selectedTitle
=
''
;
// ========== AUTO CLERK TOKEN SYNC ==========
// ========== AUTO MEMOS TOKEN SYNC ==========
/**
async
function
tryExtractToken
():
Promise
<
string
|
null
>
{
* Detect nếu đang ở trang OpenNotion frontend → auto lấy Clerk token
const
maxRetries
=
3
;
* Clerk SDK loads async, nên cần poll chờ nó sẵn sàng
const
retryDelay
=
500
;
*/
async
function
tryExtractClerkToken
():
Promise
<
string
|
null
>
{
for
(
let
attempt
=
0
;
attempt
<
maxRetries
;
attempt
++
)
{
const
maxRetries
=
5
;
try
{
const
retryDelay
=
800
;
// ms
// 1. Memos cookie
const
match
=
document
.
cookie
.
match
(
/
(?:
^|;
)
memos
\.
access-token=
([^
;
]
+
)
/
);
for
(
let
attempt
=
0
;
attempt
<
maxRetries
;
attempt
++
)
{
if
(
match
&&
match
[
1
])
return
match
[
1
];
try
{
// 1. Try Memos cookie
// 2. localStorage
const
match
=
document
.
cookie
.
match
(
/
(?:
^|;
)
memos
\.
access-token=
([^
;
]
+
)
/
);
for
(
let
i
=
0
;
i
<
localStorage
.
length
;
i
++
)
{
if
(
match
&&
match
[
1
])
return
match
[
1
];
const
key
=
localStorage
.
key
(
i
);
if
(
key
&&
(
key
.
includes
(
'access_token'
)
||
key
.
includes
(
'token'
)))
{
// 2. Try Memos API token from localStorage (if any)
const
val
=
localStorage
.
getItem
(
key
);
for
(
let
i
=
0
;
i
<
localStorage
.
length
;
i
++
)
{
if
(
val
&&
typeof
val
===
'string'
&&
val
.
length
>
20
&&
!
val
.
startsWith
(
'{'
))
{
const
key
=
localStorage
.
key
(
i
);
return
val
;
if
(
key
&&
(
key
.
includes
(
'token'
)
||
key
.
includes
(
'session'
)))
{
}
const
val
=
localStorage
.
getItem
(
key
);
}
if
(
val
&&
typeof
val
===
'string'
&&
val
.
length
>
50
&&
!
val
.
startsWith
(
'{'
))
{
}
// Return if looks like a JWT or long token
}
catch
{
return
val
;
// Not ready yet
}
}
}
}
if
(
attempt
<
maxRetries
-
1
)
{
await
new
Promise
((
resolve
)
=>
setTimeout
(
resolve
,
retryDelay
));
// 3. Try Clerk token
}
const
clerk
=
(
window
as
any
).
Clerk
;
}
// Skip if Clerk loaded but user not signed in
return
null
;
if
(
clerk
&&
!
clerk
.
user
)
return
null
;
}
if
(
clerk
?.
session
?.
getToken
)
{
const
token
=
await
clerk
.
session
.
getToken
();
async
function
autoSyncToken
()
{
if
(
token
)
return
token
;
const
hostname
=
window
.
location
.
hostname
;
}
const
isCanifaDomain
=
}
catch
{
hostname
===
'172.16.2.210'
||
// Clerk/Memos chưa ready, thử lại
hostname
===
'localhost'
||
}
hostname
.
includes
(
'canifa'
)
||
hostname
.
includes
(
'cucunote'
)
||
if
(
attempt
<
maxRetries
-
1
)
{
hostname
.
includes
(
'cucu-note'
);
await
new
Promise
((
resolve
)
=>
setTimeout
(
resolve
,
retryDelay
));
}
if
(
!
isCanifaDomain
)
return
;
}
return
null
;
const
token
=
await
tryExtractToken
();
}
if
(
token
)
{
chrome
.
runtime
.
sendMessage
({
/**
type
:
'SYNC_AUTH'
,
* Auto-sync: nếu đang ở trang OpenNotion, lấy Clerk token rồi gửi về background
data
:
{
memosAccessToken
:
token
},
*/
});
async
function
autoSyncClerkToken
()
{
}
// Chỉ chạy trên domain OpenNotion (kiểm tra cả localhost dev và production)
}
const
hostname
=
window
.
location
.
hostname
;
const
isOpenNotionDomain
=
autoSyncToken
();
hostname
===
'172.16.2.210'
||
hostname
===
'localhost'
||
// On-demand token request
hostname
.
includes
(
'opennotion'
)
||
chrome
.
runtime
.
onMessage
.
addListener
((
message
,
_sender
,
sendResponse
)
=>
{
hostname
.
includes
(
'cucunote'
)
||
if
(
message
.
type
===
'GET_TOKEN'
)
{
hostname
.
includes
(
'cucu-note'
);
tryExtractToken
().
then
((
token
)
=>
{
sendResponse
({
token
});
if
(
!
isOpenNotionDomain
)
return
;
});
return
true
;
const
token
=
await
tryExtractClerkToken
();
}
if
(
token
)
{
});
// Gửi token về background service worker để lưu vào chrome.storage.local
chrome
.
runtime
.
sendMessage
({
// ========== TEXT SELECTION → AUTO SAVE ==========
type
:
'SYNC_AUTH'
,
data
:
{
clerkSessionToken
:
token
},
document
.
addEventListener
(
'mouseup'
,
handleTextSelection
);
});
document
.
addEventListener
(
'keydown'
,
async
(
e
)
=>
{
}
if
((
e
.
key
===
' '
||
e
.
key
===
'Enter'
)
&&
selectedText
.
length
>
0
)
{
}
const
target
=
e
.
target
as
HTMLElement
;
if
(
!
target
||
(
target
.
tagName
!==
'INPUT'
&&
target
.
tagName
!==
'TEXTAREA'
&&
!
target
.
isContentEditable
))
{
// Chạy auto-sync khi content script load
e
.
preventDefault
();
autoSyncClerkToken
();
e
.
stopPropagation
();
await
handleAutoSave
();
// Cũng lắng nghe request từ popup để sync on-demand
return
;
chrome
.
runtime
.
onMessage
.
addListener
((
message
,
_sender
,
sendResponse
)
=>
{
}
if
(
message
.
type
===
'GET_CLERK_TOKEN'
)
{
}
tryExtractClerkToken
().
then
((
token
)
=>
{
});
sendResponse
({
token
});
document
.
addEventListener
(
'keyup'
,
handleTextSelection
);
});
return
true
;
// keep channel open for async
function
handleTextSelection
()
{
}
try
{
});
setTimeout
(()
=>
{
const
selection
=
window
.
getSelection
();
// Listen khi user bôi đen text
if
(
!
selection
||
selection
.
rangeCount
===
0
)
{
document
.
addEventListener
(
'mouseup'
,
handleTextSelection
);
selectedText
=
''
;
document
.
addEventListener
(
'keydown'
,
async
(
e
)
=>
{
removeQuickHint
();
// Nếu nhấn Space hoặc Enter sau khi đã bôi đen text → tự động lưu ngay
return
;
if
((
e
.
key
===
' '
||
e
.
key
===
'Enter'
)
&&
selectedText
.
length
>
0
)
{
}
// Chỉ prevent default nếu không phải trong input/textarea
const
target
=
e
.
target
as
HTMLElement
;
const
text
=
selection
.
toString
().
trim
();
if
(
!
target
||
(
target
.
tagName
!==
'INPUT'
&&
target
.
tagName
!==
'TEXTAREA'
&&
!
target
.
isContentEditable
))
{
if
(
text
.
length
===
0
)
{
e
.
preventDefault
();
selectedText
=
''
;
e
.
stopPropagation
();
removeQuickHint
();
await
handleAutoSave
();
return
;
return
;
}
}
}
selectedText
=
text
;
});
selectedUrl
=
window
.
location
.
href
;
document
.
addEventListener
(
'keyup'
,
handleTextSelection
);
selectedTitle
=
document
.
title
;
showQuickHint
();
function
handleTextSelection
()
{
},
50
);
try
{
}
catch
{
// Delay một chút để đảm bảo selection đã hoàn tất
// Silently ignore
setTimeout
(()
=>
{
}
const
selection
=
window
.
getSelection
();
}
if
(
!
selection
||
selection
.
rangeCount
===
0
)
{
async
function
handleAutoSave
()
{
selectedText
=
''
;
if
(
!
selectedText
)
return
;
removeQuickHint
();
return
;
try
{
}
showToast
(
'Đang lưu...'
,
'loading'
);
const
text
=
selection
.
toString
().
trim
();
chrome
.
runtime
.
sendMessage
({
type
:
'SAVE_NOTE'
,
if
(
text
.
length
===
0
)
{
data
:
{
selectedText
=
''
;
text
:
selectedText
,
removeQuickHint
();
url
:
selectedUrl
,
return
;
title
:
selectedTitle
,
}
},
},
(
response
)
=>
{
// Lưu text đã chọn + metadata
if
(
chrome
.
runtime
.
lastError
)
{
selectedText
=
text
;
showToast
(
`Lỗi:
${
chrome
.
runtime
.
lastError
.
message
||
'Extension error'
}
`
,
'error'
);
selectedUrl
=
window
.
location
.
href
;
return
;
selectedTitle
=
document
.
title
;
}
// Hiện hint nhỏ để user biết có thể nhấn Space/Enter
if
(
response
?.
success
)
{
showQuickHint
();
showToast
(
'Đã lưu vào Canifa Note'
,
'success'
);
},
50
);
window
.
getSelection
()?.
removeAllRanges
();
}
catch
{
selectedText
=
''
;
// Silently ignore selection errors
removeQuickHint
();
}
}
else
{
}
const
reason
=
response
?.
error
||
'Không thể lưu'
;
showToast
(
reason
,
'error'
);
async
function
handleAutoSave
()
{
}
if
(
!
selectedText
)
return
;
});
}
catch
(
err
:
any
)
{
try
{
showToast
(
err
?.
message
||
'Lỗi khi lưu'
,
'error'
);
// Show loading toast
}
showToast
(
'Đang lưu...'
,
'loading'
);
}
// Gửi message đến background để save
// ========== UI: Quick Hint ==========
chrome
.
runtime
.
sendMessage
({
type
:
'SAVE_NOTE'
,
function
showQuickHint
()
{
data
:
{
removeQuickHint
();
text
:
selectedText
,
url
:
selectedUrl
,
const
hint
=
document
.
createElement
(
'div'
);
title
:
selectedTitle
,
hint
.
id
=
'cucu-quick-hint'
;
},
hint
.
textContent
=
'Space hoặc Enter để lưu nhanh'
;
},
(
response
)
=>
{
if
(
chrome
.
runtime
.
lastError
)
{
hint
.
style
.
cssText
=
`
showToast
(
`❌ Lỗi:
${
chrome
.
runtime
.
lastError
.
message
||
'Extension error'
}
`
,
'error'
);
position: fixed;
return
;
bottom: 20px;
}
right: 20px;
background: hsl(240 10% 3.9%);
if
(
response
?.
success
)
{
color: hsl(0 0% 98%);
showToast
(
'✅ Đã lưu vào CuCu Note!'
,
'success'
);
padding: 10px 16px;
// Clear selection
border-radius: 8px;
window
.
getSelection
()?.
removeAllRanges
();
font-size: 13px;
selectedText
=
''
;
font-weight: 500;
removeQuickHint
();
border: 1px solid hsl(240 3.7% 15.9%);
}
else
{
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
const
reason
=
response
?.
error
||
'Không thể lưu note'
;
z-index: 999999;
showToast
(
`❌
${
reason
}
`
,
'error'
);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
animation: cucuSlideUp 0.2s ease-out;
});
pointer-events: none;
}
catch
(
err
:
any
)
{
`
;
showToast
(
`❌
${
err
?.
message
||
'Lỗi khi lưu note'
}
`, 'error');
}
if
(
!
document
.
getElementById
(
'cucu-hint-style'
))
{
}
const
style
=
document
.
createElement
(
'style'
);
style
.
id
=
'cucu-hint-style'
;
function showQuickHint() {
style
.
textContent
=
`
// Hiện hint nhỏ "Nhấn Space để lưu" với gradient đẹp
@keyframes cucuSlideUp {
removeQuickHint();
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
const hint = document.createElement('div');
}
hint.id = 'cucu-quick-hint';
`
;
hint.textContent = '💡 Nhấn Space hoặc Enter để lưu nhanh';
document
.
head
.
appendChild
(
style
);
}
// Gradient màu đẹp
hint.style.cssText = `
try
{
position
:
fixed
;
document
.
body
.
appendChild
(
hint
);
bottom
:
20
px
;
}
catch
{
right
:
20
px
;
document
.
documentElement
.
appendChild
(
hint
);
background
:
linear
-
gradient
(
135
deg
,
#
a0845c
0
%
,
#
8
b6914
100
%
);
}
color
:
white
;
padding
:
12
px
20
px
;
setTimeout
(()
=>
removeQuickHint
(),
3000
);
border
-
radius
:
8
px
;
}
font
-
size
:
14
px
;
font
-
weight
:
500
;
function
removeQuickHint
()
{
box
-
shadow
:
0
4
px
12
px
rgba
(
0
,
0
,
0
,
0.15
);
const
hint
=
document
.
getElementById
(
'cucu-quick-hint'
);
z
-
index
:
999999
;
if
(
hint
)
{
font
-
family
:
-
apple
-
system
,
BlinkMacSystemFont
,
'Segoe UI'
,
Roboto
,
sans
-
serif
;
try
{
hint
.
remove
();
}
catch
{
/* ignore */
}
animation
:
cucuSlideUp
0.3
s
ease
-
out
;
}
pointer
-
events
:
none
;
}
`;
// ========== UI: Toast ==========
// Inject animation CSS
if (!document.getElementById('cucu-hint-style')) {
function
showToast
(
message
:
string
,
type
:
'success'
|
'error'
|
'loading'
=
'success'
)
{
const style = document.createElement('style');
const
oldToast
=
document
.
getElementById
(
'cucu-toast'
);
style.id = 'cucu-hint-style';
if
(
oldToast
)
{
style.textContent = `
try
{
oldToast
.
remove
();
}
catch
{
/* ignore */
}
@
keyframes
cucuSlideUp
{
}
from
{
opacity
:
0
;
const
toast
=
document
.
createElement
(
'div'
);
transform
:
translateY
(
20
px
);
toast
.
id
=
'cucu-toast'
;
}
to
{
const
icons
=
{
success
:
'✓'
,
error
:
'✕'
,
loading
:
'⟳'
};
opacity
:
1
;
toast
.
textContent
=
`
${
icons
[
type
]}
${
message
}
`
;
transform
:
translateY
(
0
);
}
const
colors
=
{
}
success
:
'hsl(142 71% 45%)'
,
`;
error
:
'hsl(0 72% 51%)'
,
document.head.appendChild(style);
loading
:
'hsl(240 5% 64.9%)'
,
}
};
try {
toast
.
style
.
cssText
=
`
document.body.appendChild(hint);
position: fixed;
} catch (e) {
top: 20px;
document.documentElement.appendChild(hint);
right: 20px;
}
background: hsl(240 10% 3.9%);
color:
${
colors
[
type
]}
;
// Auto ẩn sau 3 giây
padding: 12px 18px;
setTimeout(() => {
border-radius: 8px;
removeQuickHint();
font-size: 13px;
}, 3000);
font-weight: 500;
}
border: 1px solid hsl(240 3.7% 15.9%);
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
function removeQuickHint() {
z-index: 999999;
const hint = document.getElementById('cucu-quick-hint');
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
if (hint) {
animation: cucuToastSlide 0.25s ease-out;
try {
max-width: 320px;
hint.remove();
word-wrap: break-word;
} catch (e) {
`
;
// Ignore
}
if
(
!
document
.
getElementById
(
'cucu-toast-style'
))
{
}
const
style
=
document
.
createElement
(
'style'
);
}
style
.
id
=
'cucu-toast-style'
;
style
.
textContent
=
`
function showToast(message: string, type: 'success' | 'error' | 'loading' = 'success') {
@keyframes cucuToastSlide {
// Remove toast cũ nếu có
from { opacity: 0; transform: translateX(100%); }
const oldToast = document.getElementById('cucu-toast');
to { opacity: 1; transform: translateX(0); }
if (oldToast) {
}
try {
`
;
oldToast.remove();
try
{
document
.
head
.
appendChild
(
style
);
}
catch
{
/* ignore */
}
} catch (e) {
}
// Ignore
}
try
{
}
document
.
body
.
appendChild
(
toast
);
}
catch
{
const toast = document.createElement('div');
try
{
document
.
documentElement
.
appendChild
(
toast
);
}
catch
{
return
;
}
toast.id = 'cucu-toast';
}
toast.textContent = message;
if
(
type
!==
'loading'
)
{
// Màu sắc đẹp theo type (gradient)
setTimeout
(()
=>
{
const colors = {
if
(
toast
.
parentNode
)
{
success: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
toast
.
style
.
animation
=
'cucuToastSlide 0.2s ease-out reverse'
;
error: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
setTimeout
(()
=>
{
loading: 'linear-gradient(135deg, #a0845c 0%, #8b6914 100%)',
try
{
toast
.
remove
();
}
catch
{
/* ignore */
}
};
},
200
);
}
toast.style.cssText = `
},
3000
);
position
:
fixed
;
}
top
:
20
px
;
}
right
:
20
px
;
background
:
$
{
colors
[
type
]};
color
:
white
;
padding
:
16
px
24
px
;
border
-
radius
:
12
px
;
font
-
size
:
15
px
;
font
-
weight
:
500
;
box
-
shadow
:
0
8
px
24
px
rgba
(
0
,
0
,
0
,
0.2
);
z
-
index
:
999999
;
font
-
family
:
-
apple
-
system
,
BlinkMacSystemFont
,
'Segoe UI'
,
Roboto
,
sans
-
serif
;
animation
:
cucuToastSlide
0.4
s
ease
-
out
;
max
-
width
:
350
px
;
word
-
wrap
:
break
-
word
;
`;
// Inject animation CSS
if (!document.getElementById('cucu-toast-style')) {
const style = document.createElement('style');
style.id = 'cucu-toast-style';
style.textContent = `
@
keyframes
cucuToastSlide
{
from
{
opacity
:
0
;
transform
:
translateX
(
100
%
);
}
to
{
opacity
:
1
;
transform
:
translateX
(
0
);
}
}
`;
try {
document.head.appendChild(style);
} catch (e) {
// Ignore
}
}
try {
document.body.appendChild(toast);
} catch (e) {
try {
document.documentElement.appendChild(toast);
} catch (e2) {
return;
}
}
// Auto ẩn sau 3 giây (trừ loading)
if (type !== 'loading') {
setTimeout(() => {
if (toast.parentNode) {
toast.style.animation = 'cucuToastSlide 0.3s ease-out reverse';
setTimeout(() => {
try {
toast.remove();
} catch (e) {
// Ignore
}
}, 300);
}
}, 3000);
}
}
extension/src/popup/popup.css
View file @
25b29d27
/* ================================================================
/* ================================================================
CuCu Note Extension — Premium Design System
Canifa Note Extension — shadcn/ui Design System
Modern, rounded, glassmorphic UI with warm brown palette
Dark-first, token-driven, Canifa red accent
================================================================ */
================================================================ */
/* ---- Light Theme (Paper — warm brown/amber) ---- */
/* ---- Design Tokens ---- */
:root
{
:root
{
--background
:
oklch
(
0.95
0.015
75
);
/* Surface */
--foreground
:
oklch
(
0.25
0.02
65
);
--background
:
hsl
(
240
10%
3.9%
);
--card
:
oklch
(
0.98
0.008
80
);
--foreground
:
hsl
(
0
0%
98%
);
--card-foreground
:
oklch
(
0.22
0.015
68
);
--card
:
hsl
(
240
10%
3.9%
);
--popover
:
oklch
(
0.98
0.008
80
);
--card-foreground
:
hsl
(
0
0%
98%
);
--popover-foreground
:
oklch
(
0.25
0.02
65
);
--popover
:
hsl
(
240
10%
3.9%
);
--primary
:
oklch
(
0.45
0.08
45
);
--popover-foreground
:
hsl
(
0
0%
98%
);
--primary-foreground
:
oklch
(
0.98
0.008
80
);
--secondary
:
oklch
(
0.92
0.025
70
);
/* Canifa Red Accent */
--secondary-foreground
:
oklch
(
0.35
0.03
60
);
--primary
:
hsl
(
2
78%
53%
);
--muted
:
oklch
(
0.9
0.025
75
);
--primary-foreground
:
hsl
(
0
0%
100%
);
--muted-foreground
:
oklch
(
0.5
0.02
68
);
--accent
:
oklch
(
0.88
0.035
55
);
/* Secondary / Muted */
--accent-foreground
:
oklch
(
0.25
0.02
65
);
--secondary
:
hsl
(
240
3.7%
15.9%
);
--destructive
:
oklch
(
0.48
0.15
25
);
--secondary-foreground
:
hsl
(
0
0%
98%
);
--border
:
oklch
(
0.88
0.018
72
);
--muted
:
hsl
(
240
3.7%
15.9%
);
--input
:
oklch
(
0.8
0.03
75
);
--muted-foreground
:
hsl
(
240
5%
64.9%
);
--ring
:
oklch
(
0.45
0.08
45
);
--accent
:
hsl
(
240
3.7%
15.9%
);
--radius
:
12px
;
--accent-foreground
:
hsl
(
0
0%
98%
);
--success
:
oklch
(
0.6
0.15
145
);
--warning
:
oklch
(
0.7
0.12
75
);
/* Feedback */
--shadow-sm
:
0
1px
2px
oklch
(
0
0
0
/
0.04
);
--destructive
:
hsl
(
0
62.8%
30.6%
);
--shadow-md
:
0
4px
12px
oklch
(
0
0
0
/
0.06
);
--destructive-foreground
:
hsl
(
0
0%
98%
);
--shadow-lg
:
0
8px
24px
oklch
(
0
0
0
/
0.08
);
--success
:
hsl
(
142
71%
45%
);
--font-sans
:
'Inter'
,
ui-sans-serif
,
system-ui
,
-apple-system
,
BlinkMacSystemFont
,
"Segoe UI"
,
Roboto
,
sans-serif
;
--success-foreground
:
hsl
(
0
0%
100%
);
}
--warning
:
hsl
(
48
96%
53%
);
/* ---- Dark Theme ---- */
/* Border / Input / Ring */
[
data-theme
=
"dark"
]
{
--border
:
hsl
(
240
3.7%
15.9%
);
--background
:
oklch
(
0.16
0.008
60
);
--input
:
hsl
(
240
3.7%
15.9%
);
--foreground
:
oklch
(
0.9
0.012
75
);
--ring
:
hsl
(
2
78%
53%
);
--card
:
oklch
(
0.20
0.01
62
);
--card-foreground
:
oklch
(
0.9
0.012
75
);
/* Radius */
--popover
:
oklch
(
0.20
0.01
62
);
--radius
:
8px
;
--popover-foreground
:
oklch
(
0.88
0.01
72
);
--primary
:
oklch
(
0.65
0.1
45
);
/* Typography */
--primary-foreground
:
oklch
(
0.15
0.008
60
);
--font-sans
:
"Geist"
,
ui-sans-serif
,
system-ui
,
-apple-system
,
BlinkMacSystemFont
,
"Segoe UI"
,
Roboto
,
sans-serif
;
--secondary
:
oklch
(
0.26
0.012
65
);
--font-mono
:
"Geist Mono"
,
ui-monospace
,
monospace
;
--secondary-foreground
:
oklch
(
0.85
0.01
72
);
--muted
:
oklch
(
0.23
0.01
62
);
/* Shadows */
--muted-foreground
:
oklch
(
0.6
0.015
70
);
--shadow-sm
:
0
1px
2px
0
rgb
(
0
0
0
/
0.05
);
--accent
:
oklch
(
0.28
0.015
55
);
--shadow-md
:
0
4px
6px
-1px
rgb
(
0
0
0
/
0.1
),
0
2px
4px
-2px
rgb
(
0
0
0
/
0.1
);
--accent-foreground
:
oklch
(
0.82
0.012
68
);
--destructive
:
oklch
(
0.55
0.1
25
);
/* Motion */
--border
:
oklch
(
0.30
0.012
62
);
--duration-fast
:
150ms
;
--input
:
oklch
(
0.35
0.015
65
);
--duration-normal
:
200ms
;
--ring
:
oklch
(
0.65
0.1
45
);
--ease-out
:
cubic-bezier
(
0.16
,
1
,
0.3
,
1
);
--success
:
oklch
(
0.65
0.15
145
);
}
--warning
:
oklch
(
0.75
0.12
75
);
--shadow-sm
:
0
1px
2px
oklch
(
0
0
0
/
0.15
);
/* ================================================================
--shadow-md
:
0
4px
12px
oklch
(
0
0
0
/
0.2
);
Base Reset
--shadow-lg
:
0
8px
24px
oklch
(
0
0
0
/
0.25
);
================================================================ */
}
*,
*
::before
,
*
::after
{
box-sizing
:
border-box
;
/* ================================================================
margin
:
0
;
Base Reset
padding
:
0
;
================================================================ */
}
*
{
box-sizing
:
border-box
;
body
{
margin
:
0
;
font-family
:
var
(
--font-sans
);
padding
:
0
;
background
:
var
(
--background
);
}
color
:
var
(
--foreground
);
font-size
:
14px
;
body
{
font-weight
:
400
;
font-family
:
var
(
--font-sans
);
line-height
:
1.5
;
background
:
var
(
--background
);
-webkit-font-smoothing
:
antialiased
;
color
:
var
(
--foreground
);
-moz-osx-font-smoothing
:
grayscale
;
font-size
:
13px
;
width
:
380px
;
line-height
:
1.5
;
min-height
:
200px
;
-webkit-font-smoothing
:
antialiased
;
overflow-x
:
hidden
;
-moz-osx-font-smoothing
:
grayscale
;
}
width
:
380px
;
min-height
:
200px
;
/* ================================================================
overflow-x
:
hidden
;
Animations
}
================================================================ */
@keyframes
fadeIn
{
/* ================================================================
from
{
opacity
:
0
;
transform
:
translateY
(
4px
);
}
Animations
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
================================================================ */
}
@keyframes
fadeIn
{
from
{
@keyframes
slideUp
{
opacity
:
0
;
from
{
opacity
:
0
;
transform
:
translateY
(
8px
);
}
transform
:
translateY
(
6px
);
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
}
}
to
{
@keyframes
spin
{
opacity
:
1
;
from
{
transform
:
rotate
(
0deg
);
}
transform
:
translateY
(
0
);
to
{
transform
:
rotate
(
360deg
);
}
}
}
}
@keyframes
pulse-dot
{
@keyframes
slideUp
{
0
%,
100
%
{
opacity
:
1
;
}
from
{
50
%
{
opacity
:
0.5
;
}
opacity
:
0
;
}
transform
:
translateY
(
12px
);
}
/* ================================================================
Popup Container
to
{
================================================================ */
opacity
:
1
;
.popup-container
{
transform
:
translateY
(
0
);
display
:
flex
;
}
flex-direction
:
column
;
}
min-height
:
100%
;
animation
:
fadeIn
var
(
--duration-normal
)
var
(
--ease-out
);
@keyframes
pulse
{
}
0
%,
/* ================================================================
100
%
{
Header
opacity
:
1
;
================================================================ */
}
.header
{
display
:
flex
;
50
%
{
align-items
:
center
;
opacity
:
0.5
;
justify-content
:
space-between
;
}
padding
:
12px
16px
;
}
border-bottom
:
1px
solid
var
(
--border
);
background
:
var
(
--card
);
@keyframes
spin
{
}
from
{
transform
:
rotate
(
0deg
);
.header-brand
{
}
display
:
flex
;
align-items
:
center
;
to
{
gap
:
8px
;
transform
:
rotate
(
360deg
);
font-weight
:
600
;
}
font-size
:
14px
;
}
color
:
var
(
--foreground
);
letter-spacing
:
-0.02em
;
@keyframes
glow
{
}
0
%,
.header-brand-logo
{
100
%
{
width
:
22px
;
box-shadow
:
0
0
4px
oklch
(
0.6
0.15
145
/
0.3
);
height
:
22px
;
}
border-radius
:
4px
;
object-fit
:
contain
;
50
%
{
}
box-shadow
:
0
0
10px
oklch
(
0.6
0.15
145
/
0.5
);
}
.header-brand-name
{
}
font-weight
:
700
;
color
:
var
(
--primary
);
@keyframes
successPop
{
letter-spacing
:
0.02em
;
0
%
{
}
transform
:
scale
(
0.9
);
opacity
:
0
;
.header-brand-suffix
{
}
font-weight
:
400
;
color
:
var
(
--muted-foreground
);
50
%
{
font-size
:
13px
;
transform
:
scale
(
1.02
);
}
}
.header-actions
{
100
%
{
display
:
flex
;
transform
:
scale
(
1
);
align-items
:
center
;
opacity
:
1
;
gap
:
4px
;
}
}
}
.header-status
{
/* ================================================================
display
:
flex
;
Popup Container
align-items
:
center
;
================================================================ */
gap
:
6px
;
.popup-container
{
font-size
:
12px
;
display
:
flex
;
font-weight
:
500
;
flex-direction
:
column
;
color
:
var
(
--muted-foreground
);
min-height
:
100%
;
}
animation
:
fadeIn
0.2s
ease-out
;
}
/* Status Dots */
.status-dot
{
/* ================================================================
width
:
6px
;
Header
height
:
6px
;
================================================================ */
border-radius
:
50%
;
.header
{
flex-shrink
:
0
;
display
:
flex
;
}
align-items
:
center
;
justify-content
:
space-between
;
.status-dot.connected
{
padding
:
14px
18px
;
background
:
var
(
--success
);
background
:
var
(
--card
);
box-shadow
:
0
0
6px
hsl
(
142
71%
45%
/
0.4
);
border-bottom
:
1px
solid
var
(
--border
);
animation
:
pulse-dot
2s
ease-in-out
infinite
;
backdrop-filter
:
blur
(
10px
);
}
}
.status-dot.disconnected
{
.header-brand
{
background
:
var
(
--muted-foreground
);
display
:
flex
;
}
align-items
:
center
;
gap
:
8px
;
.status-text
{
font-weight
:
700
;
font-size
:
11px
;
font-size
:
15px
;
color
:
var
(
--muted-foreground
);
color
:
var
(
--foreground
);
}
letter-spacing
:
-0.01em
;
}
/* ================================================================
Content
.header-brand-icon
{
================================================================ */
font-size
:
20px
;
.content
{
filter
:
drop-shadow
(
0
1px
2px
oklch
(
0
0
0
/
0.1
));
padding
:
14px
16px
;
}
animation
:
fadeIn
0.2s
var
(
--ease-out
);
}
.header-status
{
display
:
flex
;
/* ================================================================
align-items
:
center
;
Note Form
gap
:
6px
;
================================================================ */
font-size
:
11px
;
.note-form
{
font-weight
:
500
;
display
:
flex
;
color
:
var
(
--muted-foreground
);
flex-direction
:
column
;
}
animation
:
slideUp
0.2s
var
(
--ease-out
);
}
.status-dot
{
width
:
8px
;
/* Textarea */
height
:
8px
;
.note-textarea
{
border-radius
:
50%
;
display
:
block
;
flex-shrink
:
0
;
width
:
100%
;
}
min-height
:
96px
;
max-height
:
200px
;
.status-dot.status-connected
{
resize
:
vertical
;
background
:
var
(
--success
);
border
:
1px
solid
var
(
--border
);
animation
:
glow
2s
ease-in-out
infinite
;
border-radius
:
var
(
--radius
);
}
background
:
var
(
--background
);
color
:
var
(
--foreground
);
.status-dot.status-offline
{
padding
:
10px
12px
;
background
:
var
(
--destructive
);
font-size
:
14px
;
}
font-family
:
var
(
--font-sans
);
line-height
:
1.6
;
.status-text
{
transition
:
border-color
var
(
--duration-fast
),
box-shadow
var
(
--duration-fast
);
font-size
:
11px
;
}
}
.note-textarea
::placeholder
{
/* ================================================================
color
:
var
(
--muted-foreground
);
Content
}
================================================================ */
.content
{
.note-textarea
:focus
{
padding
:
16px
18px
;
outline
:
none
;
animation
:
fadeIn
0.3s
ease-out
;
border-color
:
var
(
--ring
);
}
box-shadow
:
0
0
0
2px
hsl
(
2
78%
53%
/
0.15
);
}
/* ================================================================
Note Form
/* ================================================================
================================================================ */
Source Info
.note-form
{
================================================================ */
display
:
flex
;
.source-info
{
flex-direction
:
column
;
display
:
flex
;
animation
:
slideUp
0.25s
ease-out
;
align-items
:
center
;
}
gap
:
6px
;
font-size
:
12px
;
/* ---- Textarea ---- */
color
:
var
(
--muted-foreground
);
.note-textarea
{
padding
:
6px
10px
;
display
:
block
;
background
:
var
(
--secondary
);
width
:
100%
;
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
min-height
:
100px
;
margin-top
:
8px
;
max-height
:
200px
;
border
:
1px
solid
var
(
--border
);
resize
:
vertical
;
overflow
:
hidden
;
border
:
1.5px
solid
var
(
--border
);
text-overflow
:
ellipsis
;
border-radius
:
var
(
--radius
);
white-space
:
nowrap
;
background
:
var
(
--background
);
}
color
:
var
(
--foreground
);
padding
:
12px
14px
;
.source-icon
{
font-size
:
14px
;
flex-shrink
:
0
;
font-family
:
inherit
;
width
:
14px
;
line-height
:
1.6
;
height
:
14px
;
transition
:
border-color
0.2s
ease
,
box-shadow
0.2s
ease
;
color
:
var
(
--muted-foreground
);
}
}
.note-textarea
::placeholder
{
/* ================================================================
color
:
var
(
--muted-foreground
);
Labels
font-style
:
italic
;
================================================================ */
}
.label
{
display
:
block
;
.note-textarea
:focus
{
font-size
:
12px
;
outline
:
none
;
font-weight
:
500
;
border-color
:
var
(
--ring
);
margin-bottom
:
6px
;
box-shadow
:
0
0
0
3px
oklch
(
0.45
0.08
45
/
0.1
);
color
:
var
(
--muted-foreground
);
}
letter-spacing
:
0.01em
;
}
/* ================================================================
Source Info
.label-hint
{
================================================================ */
font-size
:
11px
;
.source-info
{
color
:
var
(
--muted-foreground
);
display
:
flex
;
margin-top
:
4px
;
align-items
:
center
;
opacity
:
0.8
;
gap
:
8px
;
}
font-size
:
11px
;
color
:
var
(
--muted-foreground
);
/* ================================================================
padding
:
8px
12px
;
Tags
background
:
var
(
--muted
);
================================================================ */
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
.tag-chips
{
margin-top
:
10px
;
display
:
flex
;
border
:
1px
solid
var
(
--border
);
flex-wrap
:
wrap
;
}
gap
:
5px
;
margin-top
:
4px
;
/* ================================================================
}
Tags Section
================================================================ */
.tag-chip
{
.label
{
display
:
inline-flex
;
display
:
block
;
align-items
:
center
;
font-size
:
11px
;
gap
:
2px
;
font-weight
:
600
;
padding
:
3px
10px
;
margin-bottom
:
8px
;
border-radius
:
9999px
;
color
:
var
(
--muted-foreground
);
font-size
:
12px
;
text-transform
:
uppercase
;
font-weight
:
500
;
letter-spacing
:
0.05em
;
cursor
:
pointer
;
}
transition
:
all
var
(
--duration-fast
)
var
(
--ease-out
);
border
:
1px
solid
var
(
--border
);
.tag-chips
{
background
:
transparent
;
display
:
flex
;
color
:
var
(
--muted-foreground
);
flex-wrap
:
wrap
;
font-family
:
var
(
--font-sans
);
gap
:
6px
;
user-select
:
none
;
margin-top
:
6px
;
}
}
.tag-chip
:hover
{
.tag-chip
{
border-color
:
hsl
(
2
78%
53%
/
0.5
);
display
:
inline-flex
;
color
:
var
(
--foreground
);
align-items
:
center
;
background
:
hsl
(
2
78%
53%
/
0.06
);
gap
:
4px
;
}
padding
:
5px
12px
;
border-radius
:
100px
;
.tag-chip.selected
{
font-size
:
12px
;
background
:
var
(
--primary
);
font-weight
:
500
;
color
:
var
(
--primary-foreground
);
cursor
:
pointer
;
border-color
:
var
(
--primary
);
transition
:
all
0.2s
cubic-bezier
(
0.4
,
0
,
0.2
,
1
);
}
border
:
1.5px
solid
var
(
--border
);
background
:
var
(
--card
);
.tag-chip.selected
:hover
{
color
:
var
(
--muted-foreground
);
filter
:
brightness
(
1.1
);
font-family
:
inherit
;
background
:
var
(
--primary
);
user-select
:
none
;
color
:
var
(
--primary-foreground
);
}
}
.tag-chip
:hover
{
.tag-chip-icon
{
border-color
:
var
(
--primary
);
font-size
:
11px
;
color
:
var
(
--primary
);
opacity
:
0.6
;
background
:
oklch
(
0.45
0.08
45
/
0.06
);
}
transform
:
translateY
(
-1px
);
box-shadow
:
var
(
--shadow-sm
);
.tag-input-inline
{
}
display
:
inline-flex
;
align-items
:
center
;
.tag-chip.selected
{
gap
:
2px
;
background
:
var
(
--primary
);
padding
:
3px
10px
;
color
:
var
(
--primary-foreground
);
border-radius
:
9999px
;
border-color
:
var
(
--primary
);
font-size
:
12px
;
box-shadow
:
0
2px
6px
oklch
(
0.45
0.08
45
/
0.2
);
border
:
1px
dashed
var
(
--border
);
}
background
:
transparent
;
color
:
var
(
--muted-foreground
);
.tag-chip.selected
:hover
{
cursor
:
pointer
;
filter
:
brightness
(
1.1
);
transition
:
all
var
(
--duration-fast
)
var
(
--ease-out
);
background
:
var
(
--primary
);
font-family
:
var
(
--font-sans
);
color
:
var
(
--primary-foreground
);
}
transform
:
translateY
(
-1px
);
}
.tag-input-inline
:hover
{
border-color
:
var
(
--primary
);
.tag-input-inline
{
color
:
var
(
--foreground
);
display
:
inline-flex
;
}
align-items
:
center
;
gap
:
4px
;
.tag-input-field
{
padding
:
5px
12px
;
border
:
none
;
border-radius
:
100px
;
outline
:
none
;
font-size
:
12px
;
background
:
transparent
;
border
:
1.5px
dashed
var
(
--border
);
font-size
:
12px
;
background
:
transparent
;
font-family
:
var
(
--font-sans
);
color
:
var
(
--muted-foreground
);
color
:
var
(
--foreground
);
cursor
:
pointer
;
width
:
70px
;
transition
:
all
0.2s
ease
;
}
font-family
:
inherit
;
}
/* ================================================================
Form Group / Select
.tag-input-inline
:hover
{
================================================================ */
border-color
:
var
(
--primary
);
.form-group
{
color
:
var
(
--primary
);
margin-bottom
:
12px
;
}
}
.tag-input-field
{
.select
{
border
:
none
;
display
:
block
;
outline
:
none
;
width
:
100%
;
background
:
transparent
;
border-radius
:
var
(
--radius
);
font-size
:
12px
;
border
:
1px
solid
var
(
--border
);
font-family
:
inherit
;
background
:
var
(
--background
);
color
:
var
(
--foreground
);
color
:
var
(
--foreground
);
width
:
80px
;
padding
:
8px
32px
8px
12px
;
}
font-size
:
13px
;
font-family
:
var
(
--font-sans
);
/* ================================================================
cursor
:
pointer
;
Workspace / Select
appearance
:
none
;
================================================================ */
background-image
:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23a1a1aa' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E")
;
.form-group
{
background-repeat
:
no-repeat
;
margin-bottom
:
14px
;
background-position
:
right
10px
center
;
}
transition
:
border-color
var
(
--duration-fast
),
box-shadow
var
(
--duration-fast
);
}
.select
{
display
:
block
;
.select
:focus
{
width
:
100%
;
outline
:
none
;
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
border-color
:
var
(
--ring
);
border
:
1.5px
solid
var
(
--border
);
box-shadow
:
0
0
0
2px
hsl
(
2
78%
53%
/
0.15
);
background
:
var
(
--background
);
}
color
:
var
(
--foreground
);
padding
:
10px
36px
10px
14px
;
/* ================================================================
font-size
:
13px
;
Input
font-family
:
inherit
;
================================================================ */
cursor
:
pointer
;
.input
{
appearance
:
none
;
display
:
block
;
background-image
:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E")
;
width
:
100%
;
background-repeat
:
no-repeat
;
border-radius
:
var
(
--radius
);
background-position
:
right
12px
center
;
border
:
1px
solid
var
(
--border
);
transition
:
border-color
0.2s
ease
,
box-shadow
0.2s
ease
;
background
:
var
(
--background
);
}
color
:
var
(
--foreground
);
padding
:
8px
12px
;
.select
:focus
{
font-size
:
13px
;
outline
:
none
;
font-family
:
var
(
--font-sans
);
border-color
:
var
(
--ring
);
transition
:
border-color
var
(
--duration-fast
),
box-shadow
var
(
--duration-fast
);
box-shadow
:
0
0
0
3px
oklch
(
0.45
0.08
45
/
0.1
);
}
}
.input
:focus
{
/* ================================================================
outline
:
none
;
Visibility Selector
border-color
:
var
(
--ring
);
================================================================ */
box-shadow
:
0
0
0
2px
hsl
(
2
78%
53%
/
0.15
);
.visibility-selector
{
}
display
:
inline-flex
;
align-items
:
center
;
.input
::placeholder
{
gap
:
5px
;
color
:
var
(
--muted-foreground
);
padding
:
6px
14px
;
}
border-radius
:
100px
;
font-size
:
12px
;
/* ================================================================
font-weight
:
500
;
Visibility Selector
border
:
1.5px
solid
var
(
--border
);
================================================================ */
background
:
var
(
--card
);
.visibility-selector
{
color
:
var
(
--muted-foreground
);
display
:
inline-flex
;
cursor
:
pointer
;
align-items
:
center
;
transition
:
all
0.2s
ease
;
gap
:
4px
;
font-family
:
inherit
;
padding
:
5px
12px
;
}
border-radius
:
var
(
--radius
);
font-size
:
12px
;
.visibility-selector
:hover
{
font-weight
:
500
;
border-color
:
var
(
--primary
);
border
:
1px
solid
var
(
--border
);
color
:
var
(
--primary
);
background
:
transparent
;
}
color
:
var
(
--muted-foreground
);
cursor
:
pointer
;
/* ================================================================
transition
:
all
var
(
--duration-fast
)
var
(
--ease-out
);
Buttons
font-family
:
var
(
--font-sans
);
================================================================ */
}
.btn
{
display
:
inline-flex
;
.visibility-selector
:hover
{
align-items
:
center
;
border-color
:
hsl
(
2
78%
53%
/
0.5
);
justify-content
:
center
;
color
:
var
(
--foreground
);
gap
:
6px
;
}
white-space
:
nowrap
;
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
/* ================================================================
font-size
:
13px
;
Buttons
font-weight
:
600
;
================================================================ */
font-family
:
inherit
;
.btn
{
cursor
:
pointer
;
display
:
inline-flex
;
border
:
none
;
align-items
:
center
;
padding
:
9px
18px
;
justify-content
:
center
;
transition
:
all
0.2s
cubic-bezier
(
0.4
,
0
,
0.2
,
1
);
gap
:
6px
;
}
white-space
:
nowrap
;
border-radius
:
var
(
--radius
);
.btn
:active
{
font-size
:
13px
;
transform
:
scale
(
0.96
);
font-weight
:
500
;
}
font-family
:
var
(
--font-sans
);
cursor
:
pointer
;
.btn-primary
{
border
:
none
;
background
:
linear-gradient
(
135deg
,
var
(
--primary
),
oklch
(
0.5
0.1
50
));
padding
:
8px
16px
;
color
:
var
(
--primary-foreground
);
transition
:
all
var
(
--duration-fast
)
var
(
--ease-out
);
box-shadow
:
0
2px
8px
oklch
(
0.45
0.08
45
/
0.25
);
}
}
.btn
:focus-visible
{
.btn-primary
:hover
{
outline
:
2px
solid
var
(
--ring
);
filter
:
brightness
(
1.08
);
outline-offset
:
2px
;
box-shadow
:
0
4px
14px
oklch
(
0.45
0.08
45
/
0.35
);
}
transform
:
translateY
(
-1px
);
}
.btn
:active
{
transform
:
scale
(
0.97
);
.btn-secondary
{
}
background
:
var
(
--secondary
);
color
:
var
(
--secondary-foreground
);
.btn-primary
{
border
:
1.5px
solid
var
(
--border
);
background
:
var
(
--primary
);
}
color
:
var
(
--primary-foreground
);
}
.btn-secondary
:hover
{
background
:
var
(
--muted
);
.btn-primary
:hover
{
transform
:
translateY
(
-1px
);
background
:
hsl
(
2
78%
48%
);
}
}
.btn-ghost
{
.btn-secondary
{
background
:
transparent
;
background
:
var
(
--secondary
);
color
:
var
(
--muted-foreground
);
color
:
var
(
--secondary-foreground
);
padding
:
6px
12px
;
border
:
1px
solid
var
(
--border
);
}
}
.btn-ghost
:hover
{
.btn-secondary
:hover
{
background
:
var
(
--muted
);
background
:
hsl
(
240
3.7%
20%
);
color
:
var
(
--foreground
);
}
}
.btn-ghost
{
.btn-sm
{
background
:
transparent
;
padding
:
6px
14px
;
color
:
var
(
--muted-foreground
);
font-size
:
12px
;
padding
:
6px
10px
;
border-radius
:
calc
(
var
(
--radius
)
-
4px
);
}
}
.btn-ghost
:hover
{
.btn
:disabled
{
background
:
var
(
--accent
);
opacity
:
0.5
;
color
:
var
(
--foreground
);
cursor
:
not-allowed
;
}
transform
:
none
!important
;
filter
:
none
!important
;
.btn-sm
{
}
padding
:
6px
12px
;
font-size
:
12px
;
/* ================================================================
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
Action Bar (bottom)
}
================================================================ */
.action-bar
{
.btn-full
{
display
:
flex
;
width
:
100%
;
align-items
:
center
;
}
justify-content
:
space-between
;
padding
:
12px
18px
14px
;
.btn
:disabled
{
border-top
:
1px
solid
var
(
--border
);
opacity
:
0.5
;
background
:
var
(
--card
);
cursor
:
not-allowed
;
}
pointer-events
:
none
;
}
.action-bar-left
{
display
:
flex
;
/* Icon Button */
align-items
:
center
;
.btn-icon
{
gap
:
6px
;
padding
:
6px
;
}
width
:
28px
;
height
:
28px
;
.action-bar-right
{
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
display
:
flex
;
background
:
transparent
;
align-items
:
center
;
color
:
var
(
--muted-foreground
);
gap
:
8px
;
border
:
none
;
}
cursor
:
pointer
;
display
:
inline-flex
;
/* ================================================================
align-items
:
center
;
Badges & Messages
justify-content
:
center
;
================================================================ */
transition
:
all
var
(
--duration-fast
)
var
(
--ease-out
);
.badge
{
font-family
:
var
(
--font-sans
);
display
:
inline-flex
;
}
align-items
:
center
;
gap
:
4px
;
.btn-icon
:hover
{
padding
:
4px
10px
;
background
:
var
(
--accent
);
border-radius
:
100px
;
color
:
var
(
--foreground
);
font-size
:
12px
;
}
font-weight
:
600
;
}
/* ================================================================
Action Bar (bottom)
.badge-success
{
================================================================ */
background
:
oklch
(
0.6
0.15
145
/
0.12
);
.action-bar
{
color
:
var
(
--success
);
display
:
flex
;
border
:
1px
solid
oklch
(
0.6
0.15
145
/
0.2
);
align-items
:
center
;
animation
:
successPop
0.3s
ease-out
;
justify-content
:
space-between
;
}
padding
:
10px
16px
12px
;
border-top
:
1px
solid
var
(
--border
);
.error
{
background
:
var
(
--card
);
padding
:
10px
14px
;
}
margin-top
:
10px
;
background
:
oklch
(
0.55
0.15
25
/
0.08
);
.action-bar-left
{
color
:
var
(
--destructive
);
display
:
flex
;
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
align-items
:
center
;
font-size
:
12px
;
gap
:
6px
;
border
:
1px
solid
oklch
(
0.55
0.15
25
/
0.15
);
}
animation
:
fadeIn
0.2s
ease-out
;
}
.action-bar-right
{
display
:
flex
;
.success
{
align-items
:
center
;
padding
:
10px
14px
;
gap
:
6px
;
margin-bottom
:
12px
;
}
background
:
oklch
(
0.6
0.15
145
/
0.08
);
color
:
var
(
--success
);
/* ================================================================
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
Badges & Messages
font-size
:
12px
;
================================================================ */
border
:
1px
solid
oklch
(
0.6
0.15
145
/
0.15
);
.badge
{
}
display
:
inline-flex
;
align-items
:
center
;
/* ================================================================
gap
:
4px
;
Loading Spinner
padding
:
3px
8px
;
================================================================ */
border-radius
:
9999px
;
.spinner
{
font-size
:
11px
;
display
:
inline-block
;
font-weight
:
500
;
width
:
14px
;
}
height
:
14px
;
border
:
2px
solid
var
(
--border
);
.badge-success
{
border-top-color
:
var
(
--primary
);
background
:
hsl
(
142
71%
45%
/
0.12
);
border-radius
:
50%
;
color
:
var
(
--success
);
animation
:
spin
0.6s
linear
infinite
;
border
:
1px
solid
hsl
(
142
71%
45%
/
0.2
);
vertical-align
:
middle
;
}
}
.badge-error
{
/* ================================================================
background
:
hsl
(
0
62.8%
30.6%
/
0.12
);
Scrollbar
color
:
hsl
(
0
72%
51%
);
================================================================ */
border
:
1px
solid
hsl
(
0
62.8%
30.6%
/
0.2
);
::-webkit-scrollbar
{
}
width
:
5px
;
}
.error
{
padding
:
8px
12px
;
::-webkit-scrollbar-track
{
margin-top
:
8px
;
background
:
transparent
;
background
:
hsl
(
0
62.8%
30.6%
/
0.1
);
}
color
:
hsl
(
0
72%
51%
);
border-radius
:
var
(
--radius
);
::-webkit-scrollbar-thumb
{
font-size
:
12px
;
background
:
var
(
--border
);
border
:
1px
solid
hsl
(
0
62.8%
30.6%
/
0.2
);
border-radius
:
100px
;
animation
:
fadeIn
var
(
--duration-fast
)
var
(
--ease-out
);
}
}
::-webkit-scrollbar-thumb:hover
{
.success
{
background
:
var
(
--muted-foreground
);
padding
:
8px
12px
;
}
margin-bottom
:
8px
;
background
:
hsl
(
142
71%
45%
/
0.1
);
/* ================================================================
color
:
var
(
--success
);
Smooth transitions on all themed elements
border-radius
:
var
(
--radius
);
================================================================ */
font-size
:
12px
;
*,
border
:
1px
solid
hsl
(
142
71%
45%
/
0.2
);
*
::before
,
}
*
::after
{
transition-property
:
background-color
,
color
,
border-color
,
box-shadow
,
opacity
;
/* Inline Banner */
transition-duration
:
0.2s
;
.inline-banner
{
transition-timing-function
:
ease
;
display
:
flex
;
}
align-items
:
center
;
gap
:
8px
;
/* Opt-out for animation elements */
padding
:
10px
12px
;
.spinner
,
background
:
var
(
--secondary
);
.spinner
*,
border
:
1px
solid
var
(
--border
);
[
class
*=
"animate-"
]
{
border-radius
:
var
(
--radius
);
transition
:
none
!important
;
margin-bottom
:
12px
;
font-size
:
12px
;
color
:
var
(
--muted-foreground
);
}
.inline-banner-icon
{
flex-shrink
:
0
;
font-size
:
14px
;
}
.inline-banner
a
{
color
:
var
(
--primary
);
text-decoration
:
underline
;
text-underline-offset
:
2px
;
cursor
:
pointer
;
}
.inline-banner
a
:hover
{
color
:
hsl
(
2
78%
60%
);
}
/* ================================================================
Settings
================================================================ */
.settings-section
{
padding
:
12px
0
;
border-bottom
:
1px
solid
var
(
--border
);
}
.settings-section
:last-child
{
border-bottom
:
none
;
}
.settings-title
{
font-size
:
13px
;
font-weight
:
600
;
margin-bottom
:
10px
;
color
:
var
(
--foreground
);
}
.settings-item
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
gap
:
8px
;
}
.settings-item-label
{
font-size
:
13px
;
font-weight
:
500
;
color
:
var
(
--foreground
);
}
.settings-item-desc
{
font-size
:
11px
;
color
:
var
(
--muted-foreground
);
margin-top
:
1px
;
}
.settings-input-group
{
display
:
flex
;
gap
:
6px
;
}
.settings-input-group
.input
{
flex
:
1
;
}
/* ================================================================
Loading Spinner
================================================================ */
.spinner
{
display
:
inline-block
;
width
:
14px
;
height
:
14px
;
border
:
2px
solid
var
(
--border
);
border-top-color
:
var
(
--primary
);
border-radius
:
50%
;
animation
:
spin
0.6s
linear
infinite
;
vertical-align
:
middle
;
}
/* ================================================================
Scrollbar
================================================================ */
::-webkit-scrollbar
{
width
:
4px
;
}
::-webkit-scrollbar-track
{
background
:
transparent
;
}
::-webkit-scrollbar-thumb
{
background
:
var
(
--border
);
border-radius
:
9999px
;
}
::-webkit-scrollbar-thumb:hover
{
background
:
var
(
--muted-foreground
);
}
}
\ No newline at end of file
extension/src/popup/popup.html
View file @
25b29d27
<!DOCTYPE html>
<!DOCTYPE html>
<html
lang=
"en"
>
<html
lang=
"en"
class=
"dark"
>
<head>
<head>
<meta
charset=
"UTF-8"
>
<meta
charset=
"UTF-8"
>
<meta
name=
"viewport"
content=
"width=device-width, initial-scale=1.0"
>
<meta
name=
"viewport"
content=
"width=device-width, initial-scale=1.0"
>
<title>
CuCu Note
</title>
<title>
Canifa Note
</title>
<link
rel=
"preconnect"
href=
"https://fonts.googleapis.com"
>
<link
rel=
"stylesheet"
href=
"./popup.css"
>
<link
rel=
"preconnect"
href=
"https://fonts.gstatic.com"
crossorigin
>
<style>
<link
href=
"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel=
"stylesheet"
>
body
{
<link
rel=
"stylesheet"
href=
"./popup.css"
>
margin
:
0
;
<style>
padding
:
0
;
body
{
width
:
380px
;
margin
:
0
;
min-height
:
380px
;
padding
:
0
;
max-height
:
560px
;
width
:
380px
;
overflow-y
:
auto
;
min-height
:
420px
;
}
max-height
:
560px
;
</style>
overflow-y
:
auto
;
</head>
}
<body>
</style>
<div
id=
"popup-root"
></div>
</head>
<script
type=
"module"
src=
"./popup.tsx"
></script>
<body>
</body>
<div
id=
"popup-root"
></div>
</html>
<script
type=
"module"
src=
"./popup.tsx"
></script>
</body>
</html>
extension/src/popup/popup.tsx
View file @
25b29d27
/**
/**
* CuCu Note Extension Popup
* Canifa Note Extension Popup
*
*
* Simple, reliable approach:
* Redesigned with shadcn/ui dark mode + Canifa branding.
* 1. On open → grab fresh Clerk token from any OpenNotion tab
* Auth: Extracts Memos access-token from open tabs.
* 2. If token found → show NoteForm
* Always shows NoteForm — no login wall.
* 3. If no token → show "Sign in" button
*/
* 4. Popup stays OPEN after save
*/
import
{
useState
,
useEffect
,
useCallback
}
from
'react'
;
import
{
createRoot
}
from
'react-dom/client'
;
import
{
useState
,
useEffect
,
useCallback
}
from
'react'
;
import
{
NoteForm
}
from
'../components/NoteForm'
;
import
{
createRoot
}
from
'react-dom/client'
;
import
'./popup.css'
;
import
{
NoteForm
}
from
'../components/NoteForm'
;
import
'./popup.css'
;
const
WEB_APP_URL
=
'http://172.16.2.210:5230'
;
const
WEB_APP_URL
=
'http://172.16.2.210:5230'
;
// ========== Token Grabber (Memos) ==========
// ========== Theme Hook ==========
async
function
grabTokenFromTabs
():
Promise
<
string
|
null
>
{
try
{
function
useTheme
()
{
const
allTabs
=
await
chrome
.
tabs
.
query
({});
const
[
theme
,
setTheme
]
=
useState
<
'light'
|
'dark'
|
'system'
>
(
'dark'
);
const
candidates
=
allTabs
.
filter
((
t
)
=>
{
const
url
=
t
.
url
||
''
;
useEffect
(()
=>
{
return
(
const
saved
=
localStorage
.
getItem
(
'cucu-theme'
)
as
'light'
|
'dark'
|
'system'
|
null
;
url
.
includes
(
'172.16.2.210'
)
||
if
(
saved
)
setTheme
(
saved
);
url
.
includes
(
'localhost:5230'
)
||
},
[]);
url
.
includes
(
'cucunote'
)
||
url
.
includes
(
'canifa'
)
useEffect
(()
=>
{
);
localStorage
.
setItem
(
'cucu-theme'
,
theme
);
});
const
root
=
document
.
documentElement
;
if
(
theme
===
'system'
)
{
for
(
const
tab
of
candidates
)
{
const
isDark
=
window
.
matchMedia
(
'(prefers-color-scheme: dark)'
).
matches
;
if
(
!
tab
.
id
)
continue
;
root
.
setAttribute
(
'data-theme'
,
isDark
?
'dark'
:
'light'
);
try
{
}
else
{
const
results
=
await
chrome
.
scripting
.
executeScript
({
root
.
setAttribute
(
'data-theme'
,
theme
);
target
:
{
tabId
:
tab
.
id
},
}
world
:
'MAIN'
,
},
[
theme
]);
func
:
()
=>
{
try
{
const
cycleTheme
=
()
=>
{
// 1. Memos access-token cookie
setTheme
((
prev
)
=>
(
prev
===
'light'
?
'dark'
:
prev
===
'dark'
?
'system'
:
'light'
));
const
match
=
document
.
cookie
.
match
(
/
(?:
^|;
)
memos
\.
access-token=
([^
;
]
+
)
/
);
};
if
(
match
&&
match
[
1
])
return
match
[
1
];
const
icon
=
theme
===
'light'
?
'☀️'
:
theme
===
'dark'
?
'🌙'
:
'🌓'
;
// 2. localStorage
return
{
theme
,
cycleTheme
,
icon
};
for
(
let
i
=
0
;
i
<
localStorage
.
length
;
i
++
)
{
}
const
key
=
localStorage
.
key
(
i
);
if
(
key
&&
(
key
.
includes
(
'access_token'
)
||
key
.
includes
(
'token'
)))
{
// ========== Token Grabber ==========
const
val
=
localStorage
.
getItem
(
key
);
if
(
val
&&
val
.
length
>
20
&&
!
val
.
startsWith
(
'{'
))
return
val
;
async
function
grabTokenFromTabs
():
Promise
<
string
|
null
>
{
}
try
{
}
const
allTabs
=
await
chrome
.
tabs
.
query
({});
}
catch
{
/* ignore */
}
const
candidates
=
allTabs
.
filter
((
t
)
=>
{
return
null
;
const
url
=
t
.
url
||
''
;
},
return
(
});
url
.
includes
(
'172.16.2.210'
)
||
const
token
=
results
[
0
]?.
result
;
url
.
includes
(
'localhost:3001'
)
||
if
(
token
&&
typeof
token
===
'string'
&&
token
.
length
>
10
)
{
url
.
includes
(
'opennotion'
)
||
await
chrome
.
storage
.
local
.
set
({
url
.
includes
(
'cucunote'
)
memosAccessToken
:
token
,
);
tokenSyncedAt
:
Date
.
now
(),
});
});
return
token
;
for
(
const
tab
of
candidates
)
{
}
if
(
!
tab
.
id
)
continue
;
}
catch
{
try
{
// Tab didn't work, try next
const
results
=
await
chrome
.
scripting
.
executeScript
({
}
target
:
{
tabId
:
tab
.
id
},
}
world
:
'MAIN'
,
}
catch
{
/* Tab query failed */
}
func
:
async
()
=>
{
try
{
// Fallback: stored token
// 1. Try Memos cookie
try
{
const
match
=
document
.
cookie
.
match
(
/
(?:
^|;
)
memos
\.
access-token=
([^
;
]
+
)
/
);
const
stored
=
await
chrome
.
storage
.
local
.
get
([
'memosAccessToken'
,
'tokenSyncedAt'
]);
if
(
match
&&
match
[
1
])
return
match
[
1
];
if
(
stored
.
memosAccessToken
)
{
const
age
=
Date
.
now
()
-
(
stored
.
tokenSyncedAt
||
0
);
// 2. Try localStorage
if
(
age
<
24
*
60
*
60
*
1000
)
{
// Memos tokens last much longer than Clerk
for
(
let
i
=
0
;
i
<
localStorage
.
length
;
i
++
)
{
return
stored
.
memosAccessToken
;
const
key
=
localStorage
.
key
(
i
);
}
if
(
key
&&
(
key
.
includes
(
'token'
)
||
key
.
includes
(
'session'
)))
{
}
const
val
=
localStorage
.
getItem
(
key
);
}
catch
{
/* storage failed */
}
if
(
val
&&
typeof
val
===
'string'
&&
val
.
length
>
50
&&
!
val
.
startsWith
(
'{'
))
{
return
val
;
return
null
;
}
}
}
}
// ========== Main App ==========
// 3. Try Clerk
function
App
()
{
const
clerk
=
(
window
as
any
).
Clerk
;
const
[
isConnected
,
setIsConnected
]
=
useState
(
false
);
if
(
clerk
?.
session
?.
getToken
)
{
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
return
await
clerk
.
session
.
getToken
();
const
[
currentUrl
,
setCurrentUrl
]
=
useState
(
''
);
}
const
[
currentTitle
,
setCurrentTitle
]
=
useState
(
''
);
}
catch
{
/* ignore */
}
const
[
pendingText
,
setPendingText
]
=
useState
(
''
);
return
null
;
},
useEffect
(()
=>
{
});
(
async
()
=>
{
const
token
=
results
[
0
]?.
result
;
// Get current tab info
if
(
token
&&
typeof
token
===
'string'
&&
token
.
length
>
50
)
{
try
{
// Save to storage for other parts of extension
const
[
tab
]
=
await
chrome
.
tabs
.
query
({
active
:
true
,
currentWindow
:
true
});
await
chrome
.
storage
.
local
.
set
({
if
(
tab
)
{
clerkSessionToken
:
token
,
setCurrentUrl
(
tab
.
url
||
''
);
clerkTokenSyncedAt
:
Date
.
now
(),
setCurrentTitle
(
tab
.
title
||
''
);
});
}
return
token
;
}
catch
{
/* ignore */
}
}
}
catch
{
// Get pending note
// This tab didn't work, try next
try
{
}
const
result
=
await
chrome
.
storage
.
local
.
get
([
'pendingNote'
]);
}
if
(
result
.
pendingNote
)
{
}
catch
{
/* Tab query failed */
}
setPendingText
(
result
.
pendingNote
.
text
||
''
);
chrome
.
storage
.
local
.
remove
([
'pendingNote'
]);
// Fallback: check storage
}
try
{
}
catch
{
/* ignore */
}
const
stored
=
await
chrome
.
storage
.
local
.
get
([
'clerkSessionToken'
,
'clerkTokenSyncedAt'
]);
if
(
stored
.
clerkSessionToken
)
{
// Grab token
const
age
=
Date
.
now
()
-
(
stored
.
clerkTokenSyncedAt
||
0
);
const
t
=
await
grabTokenFromTabs
();
if
(
age
<
5
*
60
*
1000
)
{
// less than 5 minutes old
setIsConnected
(
!!
t
);
return
stored
.
clerkSessionToken
;
setIsLoading
(
false
);
}
})();
}
},
[]);
}
catch
{
/* storage failed */
}
const
getFreshToken
=
useCallback
(
async
():
Promise
<
string
|
null
>
=>
{
return
null
;
const
fresh
=
await
grabTokenFromTabs
();
}
setIsConnected
(
!!
fresh
);
return
fresh
;
// ========== Main App ==========
},
[]);
function
App
()
{
const
handleOpenApp
=
()
=>
{
const
{
cycleTheme
,
icon
:
themeIcon
}
=
useTheme
();
chrome
.
tabs
.
create
({
url
:
`
${
WEB_APP_URL
}
`
});
};
const
[
status
,
setStatus
]
=
useState
<
'loading'
|
'connected'
|
'disconnected'
>
(
'loading'
);
const
[
currentUrl
,
setCurrentUrl
]
=
useState
(
''
);
return
(
const
[
currentTitle
,
setCurrentTitle
]
=
useState
(
''
);
<
div
className=
"popup-container"
>
const
[
pendingText
,
setPendingText
]
=
useState
(
''
);
{
/* Header */
}
<
header
className=
"header"
>
// Initialize: grab token + tab info
<
div
className=
"header-brand"
>
useEffect
(()
=>
{
<
img
src=
"/image.png"
alt=
"Canifa"
className=
"header-brand-logo"
/>
(
async
()
=>
{
<
span
className=
"header-brand-name"
>
CANIFA
</
span
>
// Get current tab info
<
span
className=
"header-brand-suffix"
>
Note
</
span
>
try
{
</
div
>
const
[
tab
]
=
await
chrome
.
tabs
.
query
({
active
:
true
,
currentWindow
:
true
});
<
div
className=
"header-actions"
>
if
(
tab
)
{
<
div
className=
"header-status"
>
setCurrentUrl
(
tab
.
url
||
''
);
{
isLoading
?
(
setCurrentTitle
(
tab
.
title
||
''
);
<
span
className=
"spinner"
style=
{
{
width
:
10
,
height
:
10
}
}
/>
}
)
:
(
}
catch
{
/* ignore */
}
<>
<
span
className=
{
`status-dot ${isConnected ? 'connected' : 'disconnected'}`
}
/>
// Get pending note from context menu / content script
<
span
className=
"status-text"
>
{
isConnected
?
'Synced'
:
'Offline'
}
</
span
>
try
{
</>
const
result
=
await
chrome
.
storage
.
local
.
get
([
'pendingNote'
]);
)
}
if
(
result
.
pendingNote
)
{
</
div
>
setPendingText
(
result
.
pendingNote
.
text
||
''
);
<
button
className=
"btn-icon"
onClick=
{
handleOpenApp
}
title=
"Open Canifa Note"
>
chrome
.
storage
.
local
.
remove
([
'pendingNote'
]);
↗
}
</
button
>
}
catch
{
/* ignore */
}
</
div
>
</
header
>
// Grab token
const
t
=
await
grabTokenFromTabs
();
{
/* Connection Banner (only when disconnected) */
}
setStatus
(
t
?
'connected'
:
'disconnected'
);
{
!
isLoading
&&
!
isConnected
&&
(
})();
<
div
className=
"content"
>
},
[]);
<
div
className=
"inline-banner"
>
<
span
className=
"inline-banner-icon"
>
⚡
</
span
>
// Fresh token getter for NoteForm — re-grabs from tab each time
<
span
>
const
getFreshToken
=
useCallback
(
async
():
Promise
<
string
|
null
>
=>
{
<
a
onClick=
{
handleOpenApp
}
>
Mở Canifa Note
</
a
>
trên trình duyệt và đăng nhập để đồng bộ.
const
fresh
=
await
grabTokenFromTabs
();
</
span
>
if
(
fresh
)
setStatus
(
'connected'
);
</
div
>
return
fresh
;
</
div
>
},
[]);
)
}
// Retry connection
{
/* Always show NoteForm */
}
const
handleRetry
=
async
()
=>
{
<
NoteForm
setStatus
(
'loading'
);
initialText=
{
pendingText
}
const
t
=
await
grabTokenFromTabs
();
initialUrl=
{
currentUrl
}
setStatus
(
t
?
'connected'
:
'disconnected'
);
initialTitle=
{
currentTitle
}
};
getToken=
{
getFreshToken
}
/>
return
(
</
div
>
<
div
className=
"popup-container"
>
);
{
/* Header */
}
}
<
header
className=
"header"
>
<
div
className=
"header-brand"
>
// Mount
<
span
className=
"header-brand-icon"
>
🐣
</
span
>
const
root
=
document
.
getElementById
(
'popup-root'
);
<
span
>
CuCu Note
</
span
>
if
(
root
)
{
<
button
className=
"btn btn-ghost btn-sm"
onClick=
{
cycleTheme
}
title=
"Toggle theme"
>
createRoot
(
root
).
render
(<
App
/>);
{
themeIcon
}
}
</
button
>
</
div
>
<
div
className=
"header-status"
>
{
status
===
'loading'
&&
(
<>
<
span
className=
"spinner"
style=
{
{
width
:
10
,
height
:
10
}
}
/>
<
span
className=
"status-text"
>
Connecting...
</
span
>
</>
)
}
{
status
===
'connected'
&&
(
<>
<
span
className=
"status-dot status-connected"
/>
<
span
className=
"status-text"
>
Connected
</
span
>
</>
)
}
{
status
===
'disconnected'
&&
(
<>
<
span
className=
"status-dot status-offline"
/>
<
span
className=
"status-text"
>
Offline
</
span
>
</>
)
}
</
div
>
</
header
>
{
/* Loading */
}
{
status
===
'loading'
&&
(
<
div
className=
"content"
style=
{
{
textAlign
:
'center'
,
padding
:
'40px'
}
}
>
<
span
className=
"spinner"
/>
Connecting...
</
div
>
)
}
{
/* Connected → NoteForm */
}
{
status
===
'connected'
&&
(
<
NoteForm
initialText=
{
pendingText
}
initialUrl=
{
currentUrl
}
initialTitle=
{
currentTitle
}
getToken=
{
getFreshToken
}
/>
)
}
{
/* Disconnected → Sign in prompt */
}
{
status
===
'disconnected'
&&
(
<
div
className=
"content"
style=
{
{
padding
:
'24px'
,
textAlign
:
'center'
}
}
>
<
div
style=
{
{
fontSize
:
'48px'
,
marginBottom
:
'16px'
}
}
>
🔐
</
div
>
<
h3
style=
{
{
margin
:
'0 0 8px'
,
fontSize
:
'16px'
,
color
:
'var(--text-primary)'
}
}
>
Sign in to CuCu Note
</
h3
>
<
p
style=
{
{
fontSize
:
'13px'
,
color
:
'var(--text-secondary)'
,
marginBottom
:
'20px'
,
lineHeight
:
1.5
}
}
>
Open the web app ad sign in, then come back here
</
p
>
<
button
className=
"btn btn-primary"
onClick=
{
()
=>
chrome
.
tabs
.
create
({
url
:
`${WEB_APP_URL}/auth`
})
}
style=
{
{
width
:
'100%'
,
padding
:
'10px'
,
fontSize
:
'14px'
,
marginBottom
:
'10px'
}
}
>
🚀 Open CuCu Note to Sign In
</
button
>
<
button
className=
"btn btn-ghost"
onClick=
{
handleRetry
}
style=
{
{
width
:
'100%'
,
padding
:
'8px'
,
fontSize
:
'13px'
}
}
>
🔄 Retry Connection
</
button
>
</
div
>
)
}
</
div
>
);
}
// Mount
const
root
=
document
.
getElementById
(
'popup-root'
);
if
(
root
)
{
createRoot
(
root
).
render
(<
App
/>);
}
extension/src/shared/api-client.ts
View file @
25b29d27
/**
/**
* API Client - Gọi backend CuCu Note
* API Client — Canifa Note (Memos backend)
* Configurable server URL + tag/workspace fetching
* Uses Memos access-token for auth
*/
*/
// Default API base URL — empty means user must configure in Settings
// Default API base URL
const
DEFAULT_API_BASE_URL
=
'http://172.16.2.210:5230'
;
const
DEFAULT_API_BASE_URL
=
'http://172.16.2.210:5230'
;
// ========== Config ==========
// ========== Config ==========
async
function
getApiBase
():
Promise
<
string
>
{
async
function
getApiBase
():
Promise
<
string
>
{
const
result
=
await
chrome
.
storage
.
local
.
get
([
'apiBaseUrl'
]);
const
result
=
await
chrome
.
storage
.
local
.
get
([
'apiBaseUrl'
]);
const
baseUrl
=
result
.
apiBaseUrl
||
DEFAULT_API_BASE_URL
;
const
baseUrl
=
result
.
apiBaseUrl
||
DEFAULT_API_BASE_URL
;
return
`
${
baseUrl
}
/api/v1`
;
return
`
${
baseUrl
}
/api/v1`
;
}
}
export
async
function
getApiBaseUrl
():
Promise
<
string
>
{
export
async
function
getApiBaseUrl
():
Promise
<
string
>
{
const
result
=
await
chrome
.
storage
.
local
.
get
([
'apiBaseUrl'
]);
const
result
=
await
chrome
.
storage
.
local
.
get
([
'apiBaseUrl'
]);
return
result
.
apiBaseUrl
||
DEFAULT_API_BASE_URL
;
return
result
.
apiBaseUrl
||
DEFAULT_API_BASE_URL
;
}
}
export
async
function
setApiBaseUrl
(
url
:
string
):
Promise
<
void
>
{
export
async
function
setApiBaseUrl
(
url
:
string
):
Promise
<
void
>
{
// Basic URL validation
const
cleaned
=
url
.
trim
().
replace
(
/
\/
+$/
,
''
);
const
cleaned
=
url
.
trim
().
replace
(
/
\/
+$/
,
''
);
if
(
cleaned
&&
!
cleaned
.
startsWith
(
'http://'
)
&&
!
cleaned
.
startsWith
(
'https://'
))
{
if
(
cleaned
&&
!
cleaned
.
startsWith
(
'http://'
)
&&
!
cleaned
.
startsWith
(
'https://'
))
{
throw
new
Error
(
'URL must start with http:// or https://'
);
throw
new
Error
(
'URL must start with http:// or https://'
);
}
}
await
chrome
.
storage
.
local
.
set
({
apiBaseUrl
:
cleaned
});
await
chrome
.
storage
.
local
.
set
({
apiBaseUrl
:
cleaned
});
}
}
// ========== Auth ==========
// ========== Auth ==========
async
function
getAuthToken
():
Promise
<
string
|
null
>
{
async
function
getAuthToken
():
Promise
<
string
|
null
>
{
const
result
=
await
chrome
.
storage
.
local
.
get
([
'memosAccessToken'
,
'clerkSessionToken'
,
'authToken'
]);
const
result
=
await
chrome
.
storage
.
local
.
get
([
'clerkSessionToken'
,
'authToken'
]);
// Priority: Memos token > Clerk token > manual token
if
(
result
.
clerkSessionToken
)
return
result
.
clerkSessionToken
;
if
(
result
.
memosAccessToken
)
return
result
.
memosAccessToken
;
return
result
.
authToken
||
null
;
if
(
result
.
clerkSessionToken
)
return
result
.
clerkSessionToken
;
}
return
result
.
authToken
||
null
;
}
export
async
function
setAuthToken
(
token
:
string
):
Promise
<
void
>
{
await
chrome
.
storage
.
local
.
set
({
authToken
:
token
});
export
async
function
setAuthToken
(
token
:
string
):
Promise
<
void
>
{
}
await
chrome
.
storage
.
local
.
set
({
authToken
:
token
});
}
export
async
function
getAuthStatus
():
Promise
<
{
isConnected
:
boolean
;
tokenLength
:
number
}
>
{
const
token
=
await
getAuthToken
();
export
async
function
getAuthStatus
():
Promise
<
{
isConnected
:
boolean
;
tokenLength
:
number
}
>
{
return
{
const
token
=
await
getAuthToken
();
isConnected
:
!!
token
&&
token
.
length
>
0
,
return
{
tokenLength
:
token
?.
length
||
0
,
isConnected
:
!!
token
&&
token
.
length
>
0
,
};
tokenLength
:
token
?.
length
||
0
,
}
};
}
/**
* Sync Clerk token from the currently active page (must be the frontend app)
// ========== API Helpers ==========
*/
export
async
function
syncClerkTokenFromPage
():
Promise
<
string
|
null
>
{
async
function
apiHeaders
():
Promise
<
Record
<
string
,
string
>>
{
try
{
const
token
=
await
getAuthToken
();
const
[
tab
]
=
await
chrome
.
tabs
.
query
({
active
:
true
,
currentWindow
:
true
});
const
headers
:
Record
<
string
,
string
>
=
{
'Content-Type'
:
'application/json'
};
if
(
!
tab
.
id
)
return
null
;
if
(
token
)
{
headers
[
'Authorization'
]
=
`Bearer
${
token
}
`
;
const
results
=
await
chrome
.
scripting
.
executeScript
({
}
target
:
{
tabId
:
tab
.
id
},
return
headers
;
func
:
async
()
=>
{
}
if
(
typeof
window
!==
'undefined'
&&
(
window
as
any
).
Clerk
?.
session
?.
getToken
)
{
try
{
// ========== Memo CRUD ==========
const
token
=
await
(
window
as
any
).
Clerk
.
session
.
getToken
();
return
token
||
null
;
export
interface
CreateMemoRequest
{
}
catch
{
content
:
string
;
return
null
;
tags
?:
string
[];
}
visibility
?:
string
;
}
}
return
null
;
},
export
interface
MemoResponse
{
});
id
:
string
;
content
:
string
;
const
token
=
results
[
0
]?.
result
||
null
;
tags
:
string
[];
if
(
token
)
{
created_at
:
string
;
await
chrome
.
storage
.
local
.
set
({
clerkSessionToken
:
token
});
}
}
return
token
;
export
async
function
createMemo
(
data
:
CreateMemoRequest
):
Promise
<
MemoResponse
>
{
}
catch
{
const
apiBase
=
await
getApiBase
();
return
null
;
const
headers
=
await
apiHeaders
();
}
}
const
response
=
await
fetch
(
`
${
apiBase
}
/memos`
,
{
method
:
'POST'
,
// ========== API Helpers ==========
headers
,
body
:
JSON
.
stringify
({
// Token is stale if synced more than 45s ago (Clerk JWT expires ~60s)
content
:
data
.
content
,
const
TOKEN_MAX_AGE_MS
=
45
_000
;
tags
:
data
.
tags
||
[],
visibility
:
data
.
visibility
||
'PRIVATE'
,
async
function
tryRefreshToken
():
Promise
<
void
>
{
}),
try
{
});
const
result
=
await
chrome
.
storage
.
local
.
get
([
'clerkTokenSyncedAt'
]);
const
syncedAt
=
result
.
clerkTokenSyncedAt
||
0
;
if
(
!
response
.
ok
)
{
const
age
=
Date
.
now
()
-
syncedAt
;
await
response
.
text
();
throw
new
Error
(
`Failed to create memo (
${
response
.
status
}
)`
);
// Only refresh if token is stale
}
if
(
age
>
TOKEN_MAX_AGE_MS
)
{
console
.
log
(
'[CuCu API] Token stale, refreshing...'
,
{
age
:
Math
.
round
(
age
/
1000
)
+
's'
});
const
result
=
await
response
.
json
();
const
response
=
await
new
Promise
<
any
>
((
resolve
)
=>
{
return
result
;
chrome
.
runtime
.
sendMessage
({
type
:
'REFRESH_TOKEN'
},
(
res
)
=>
{
}
resolve
(
res
);
});
/**
});
* Create a memo using a direct token
*/
// Background returned fresh token — save it
export
async
function
createMemoWithToken
(
token
:
string
,
data
:
CreateMemoRequest
):
Promise
<
MemoResponse
>
{
if
(
response
?.
success
&&
response
?.
token
)
{
const
apiBase
=
await
getApiBase
();
await
chrome
.
storage
.
local
.
set
({
clerkSessionToken
:
response
.
token
,
const
response
=
await
fetch
(
`
${
apiBase
}
/memos`
,
{
clerkTokenSyncedAt
:
Date
.
now
(),
method
:
'POST'
,
});
headers
:
{
console
.
log
(
'[CuCu API] ✅ Token refreshed'
,
response
.
token
.
length
,
'chars'
);
'Content-Type'
:
'application/json'
,
}
else
{
'Authorization'
:
`Bearer
${
token
}
`
,
console
.
log
(
'[CuCu API] ⚠️ Token refresh failed:'
,
response
?.
reason
||
'unknown'
);
},
}
body
:
JSON
.
stringify
({
}
content
:
data
.
content
,
}
catch
(
err
:
any
)
{
tags
:
data
.
tags
||
[],
console
.
log
(
'[CuCu API] ❌ Token refresh error:'
,
err
?.
message
);
visibility
:
data
.
visibility
||
'PRIVATE'
,
}
}),
}
});
async
function
apiHeaders
():
Promise
<
Record
<
string
,
string
>>
{
if
(
!
response
.
ok
)
{
// Try to refresh token if stale
const
errorText
=
await
response
.
text
();
await
tryRefreshToken
();
throw
new
Error
(
`Failed to save (
${
response
.
status
}
):
${
errorText
.
substring
(
0
,
100
)}
`
);
}
const
token
=
await
getAuthToken
();
const
headers
:
Record
<
string
,
string
>
=
{
'Content-Type'
:
'application/json'
};
return
await
response
.
json
();
if
(
token
)
{
}
headers
[
'Authorization'
]
=
`Bearer
${
token
}
`
;
}
// ========== Tags ==========
return
headers
;
}
export
async
function
fetchTags
():
Promise
<
string
[]
>
{
try
{
// ========== Memo CRUD ==========
const
apiBase
=
await
getApiBase
();
const
headers
=
await
apiHeaders
();
export
interface
CreateMemoRequest
{
content
:
string
;
const
response
=
await
fetch
(
`
${
apiBase
}
/memos`
,
{
headers
});
tags
?:
string
[];
if
(
!
response
.
ok
)
return
[];
visibility
?:
string
;
}
const
memos
:
any
[]
=
await
response
.
json
();
export
interface
MemoResponse
{
const
tagSet
=
new
Set
<
string
>
();
id
:
string
;
for
(
const
memo
of
memos
)
{
content
:
string
;
if
(
Array
.
isArray
(
memo
.
tags
))
{
tags
:
string
[];
memo
.
tags
.
forEach
((
t
:
string
)
=>
tagSet
.
add
(
t
));
created_at
:
string
;
}
}
const
hashTags
=
(
memo
.
content
||
''
).
match
(
/#
([
a-zA-Z0-9_
\u
00C0-
\u
024F
\u
1E00-
\u
1EFF
]
+
)
/g
);
if
(
hashTags
)
{
export
async
function
createMemo
(
data
:
CreateMemoRequest
):
Promise
<
MemoResponse
>
{
hashTags
.
forEach
((
t
:
string
)
=>
tagSet
.
add
(
t
.
slice
(
1
)));
const
apiBase
=
await
getApiBase
();
}
const
headers
=
await
apiHeaders
();
}
const
response
=
await
fetch
(
`
${
apiBase
}
/memos`
,
{
return
Array
.
from
(
tagSet
).
sort
();
method
:
'POST'
,
}
catch
{
headers
,
return
[];
body
:
JSON
.
stringify
({
}
content
:
data
.
content
,
}
tags
:
data
.
tags
||
[],
visibility
:
data
.
visibility
||
'PRIVATE'
,
// ========== Workspaces ==========
}),
});
export
interface
WorkspaceItem
{
id
:
number
;
if
(
!
response
.
ok
)
{
title
:
string
;
await
response
.
text
();
// consume body
filter
:
string
;
throw
new
Error
(
`Failed to create memo (
${
response
.
status
}
)`
);
}
}
export
async
function
fetchWorkspaces
():
Promise
<
WorkspaceItem
[]
>
{
const
result
=
await
response
.
json
();
try
{
return
result
;
const
apiBase
=
await
getApiBase
();
}
const
headers
=
await
apiHeaders
();
/**
const
response
=
await
fetch
(
`
${
apiBase
}
/shortcuts`
,
{
headers
});
* Create a memo using a direct token (from Clerk getToken())
if
(
!
response
.
ok
)
return
[];
* Bypasses storage-based token reading — always uses fresh JWT
*/
const
data
=
await
response
.
json
();
export
async
function
createMemoWithToken
(
token
:
string
,
data
:
CreateMemoRequest
):
Promise
<
MemoResponse
>
{
const
shortcuts
=
Array
.
isArray
(
data
)
?
data
:
data
.
shortcuts
||
[];
const
apiBase
=
await
getApiBase
();
return
shortcuts
.
map
((
s
:
any
)
=>
({
id
:
s
.
id
,
const
response
=
await
fetch
(
`
${
apiBase
}
/memos`
,
{
title
:
s
.
title
||
s
.
name
||
`Workspace
${
s
.
id
}
`
,
method
:
'POST'
,
filter
:
s
.
filter
||
''
,
headers
:
{
}));
'Content-Type'
:
'application/json'
,
}
catch
{
'Authorization'
:
`Bearer
${
token
}
`
,
return
[];
},
}
body
:
JSON
.
stringify
({
}
content
:
data
.
content
,
tags
:
data
.
tags
||
[],
// ========== Connection Test ==========
visibility
:
data
.
visibility
||
'PRIVATE'
,
}),
export
async
function
testConnection
():
Promise
<
{
ok
:
boolean
;
message
:
string
}
>
{
});
try
{
const
apiBase
=
await
getApiBase
();
if
(
!
response
.
ok
)
{
const
headers
=
await
apiHeaders
();
const
errorText
=
await
response
.
text
();
throw
new
Error
(
`Failed to save (
${
response
.
status
}
):
${
errorText
.
substring
(
0
,
100
)}
`
);
const
response
=
await
fetch
(
`
${
apiBase
}
/memos`
,
{
}
method
:
'GET'
,
headers
,
return
await
response
.
json
();
signal
:
AbortSignal
.
timeout
(
5000
),
}
});
// ========== Tags ==========
if
(
response
.
ok
)
{
return
{
ok
:
true
,
message
:
'Connected successfully!'
};
export
async
function
fetchTags
():
Promise
<
string
[]
>
{
}
else
{
try
{
return
{
ok
:
false
,
message
:
`Server error:
${
response
.
status
}
`
};
const
apiBase
=
await
getApiBase
();
}
const
headers
=
await
apiHeaders
();
}
catch
(
error
)
{
return
{
ok
:
false
,
message
:
`Cannot reach server:
${(
error
as
Error
).
message
}
`
};
const
response
=
await
fetch
(
`
${
apiBase
}
/memos`
,
{
headers
});
}
if
(
!
response
.
ok
)
return
[];
}
const
memos
:
any
[]
=
await
response
.
json
();
// ========== Recently Used Tags (local) ==========
// Extract unique tags from all memos
export
async
function
getRecentTags
():
Promise
<
string
[]
>
{
const
tagSet
=
new
Set
<
string
>
();
const
result
=
await
chrome
.
storage
.
local
.
get
([
'recentTags'
]);
for
(
const
memo
of
memos
)
{
return
result
.
recentTags
||
[];
if
(
Array
.
isArray
(
memo
.
tags
))
{
}
memo
.
tags
.
forEach
((
t
:
string
)
=>
tagSet
.
add
(
t
));
}
export
async
function
saveRecentTags
(
tags
:
string
[]):
Promise
<
void
>
{
// Also parse #tag from content
const
unique
=
[...
new
Set
(
tags
)].
slice
(
0
,
20
);
const
hashTags
=
(
memo
.
content
||
''
).
match
(
/#
([
a-zA-Z0-9_
\u
00C0-
\u
024F
\u
1E00-
\u
1EFF
]
+
)
/g
);
await
chrome
.
storage
.
local
.
set
({
recentTags
:
unique
});
if
(
hashTags
)
{
}
hashTags
.
forEach
((
t
:
string
)
=>
tagSet
.
add
(
t
.
slice
(
1
)));
// remove #
}
}
return
Array
.
from
(
tagSet
).
sort
();
}
catch
{
return
[];
}
}
// ========== Workspaces (Shortcuts) ==========
export
interface
WorkspaceItem
{
id
:
number
;
title
:
string
;
filter
:
string
;
}
export
async
function
fetchWorkspaces
():
Promise
<
WorkspaceItem
[]
>
{
try
{
const
apiBase
=
await
getApiBase
();
const
headers
=
await
apiHeaders
();
const
response
=
await
fetch
(
`
${
apiBase
}
/shortcuts`
,
{
headers
});
if
(
!
response
.
ok
)
return
[];
const
data
=
await
response
.
json
();
// API may return array directly or { shortcuts: [...] }
const
shortcuts
=
Array
.
isArray
(
data
)
?
data
:
data
.
shortcuts
||
[];
return
shortcuts
.
map
((
s
:
any
)
=>
({
id
:
s
.
id
,
title
:
s
.
title
||
s
.
name
||
`Workspace
${
s
.
id
}
`
,
filter
:
s
.
filter
||
''
,
}));
}
catch
{
return
[];
}
}
// ========== Connection Test ==========
export
async
function
testConnection
():
Promise
<
{
ok
:
boolean
;
message
:
string
}
>
{
try
{
const
apiBase
=
await
getApiBase
();
const
headers
=
await
apiHeaders
();
const
response
=
await
fetch
(
`
${
apiBase
}
/memos`
,
{
method
:
'GET'
,
headers
,
signal
:
AbortSignal
.
timeout
(
5000
),
});
if
(
response
.
ok
)
{
return
{
ok
:
true
,
message
:
'Connected successfully!'
};
}
else
{
return
{
ok
:
false
,
message
:
`Server error:
${
response
.
status
}
`
};
}
}
catch
(
error
)
{
return
{
ok
:
false
,
message
:
`Cannot reach server:
${(
error
as
Error
).
message
}
`
};
}
}
// ========== Recently Used Tags (local) ==========
export
async
function
getRecentTags
():
Promise
<
string
[]
>
{
const
result
=
await
chrome
.
storage
.
local
.
get
([
'recentTags'
]);
return
result
.
recentTags
||
[];
}
export
async
function
saveRecentTags
(
tags
:
string
[]):
Promise
<
void
>
{
// Keep only last 20 unique tags
const
unique
=
[...
new
Set
(
tags
)].
slice
(
0
,
20
);
await
chrome
.
storage
.
local
.
set
({
recentTags
:
unique
});
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment