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
Show 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"
:
{
"file"
:
"assets/crx-manifest.js-
SqlU4S0k
.js"
,
"file"
:
"assets/crx-manifest.js-
kDmztkte
.js"
,
"name"
:
"crx-manifest.js"
,
"src"
:
"../../../@crx/manifest"
,
"isEntry"
:
true
},
"_api-client-
Y5oDKlCr
.js"
:
{
"file"
:
"assets/api-client-
Y5oDKlCr
.js"
,
"_api-client-
CzjXLdoC
.js"
:
{
"file"
:
"assets/api-client-
CzjXLdoC
.js"
,
"name"
:
"api-client"
},
"src/background/service-worker.ts"
:
{
"file"
:
"assets/service-worker.ts-
DvMHIkFe
.js"
,
"file"
:
"assets/service-worker.ts-
C6gHU6BA
.js"
,
"name"
:
"service-worker.ts"
,
"src"
:
"src/background/service-worker.ts"
,
"isEntry"
:
true
,
"imports"
:
[
"_api-client-
Y5oDKlCr
.js"
"_api-client-
CzjXLdoC
.js"
]
},
"src/content/content-script.ts"
:
{
"file"
:
"assets/content-script.ts-
BWL85FVS
.js"
,
"file"
:
"assets/content-script.ts-
CTdB63jU
.js"
,
"name"
:
"content-script.ts"
,
"src"
:
"src/content/content-script.ts"
,
"isEntry"
:
true
},
"src/popup/popup.html"
:
{
"file"
:
"assets/popup-
DmXuB8QF
.js"
,
"file"
:
"assets/popup-
CSzsyzxK
.js"
,
"name"
:
"popup"
,
"src"
:
"src/popup/popup.html"
,
"isEntry"
:
true
,
"imports"
:
[
"_api-client-
Y5oDKlCr
.js"
"_api-client-
CzjXLdoC
.js"
],
"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
,
"name"
:
"C
uCu
Note"
,
"version"
:
"1.
0
.0"
,
"description"
:
"
Quick note-taking extension - Highlight text and save to CuCu Note
"
,
"name"
:
"C
anifa
Note"
,
"version"
:
"1.
1
.0"
,
"description"
:
"
Ghi chú nhanh từ web — Bôi đen, nhấn Space, lưu tự động
"
,
"permissions"
:
[
"activeTab"
,
"storage"
,
...
...
@@ -20,7 +20,7 @@
"content_scripts"
:
[
{
"js"
:
[
"assets/content-script.ts-
BWL85FVS
.js"
"assets/content-script.ts-
CTdB63jU
.js"
],
"matches"
:
[
"<all_urls>"
...
...
@@ -48,7 +48,7 @@
"<all_urls>"
],
"resources"
:
[
"assets/content-script.ts-
BWL85FVS
.js"
"assets/content-script.ts-
CTdB63jU
.js"
],
"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>
<html
lang=
"en"
>
<html
lang=
"en"
class=
"dark"
>
<head>
<meta
charset=
"UTF-8"
>
<meta
name=
"viewport"
content=
"width=device-width, initial-scale=1.0"
>
<title>
CuCu 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"
>
<title>
Canifa Note
</title>
<style>
body
{
margin
:
0
;
padding
:
0
;
width
:
380px
;
min-height
:
42
0px
;
min-height
:
38
0px
;
max-height
:
560px
;
overflow-y
:
auto
;
}
</style>
<script
type=
"module"
crossorigin
src=
"/assets/popup-
DmXuB8QF
.js"
></script>
<link
rel=
"modulepreload"
crossorigin
href=
"/assets/api-client-
Y5oDKlCr
.js"
>
<link
rel=
"stylesheet"
crossorigin
href=
"/assets/popup-
dYtSf2jU
.css"
>
<script
type=
"module"
crossorigin
src=
"/assets/popup-
CSzsyzxK
.js"
></script>
<link
rel=
"modulepreload"
crossorigin
href=
"/assets/api-client-
CzjXLdoC
.js"
>
<link
rel=
"stylesheet"
crossorigin
href=
"/assets/popup-
C6DDov6Y
.css"
>
</head>
<body>
<div
id=
"popup-root"
></div>
...
...
extension/manifest.json
View file @
25b29d27
{
"manifest_version"
:
3
,
"name"
:
"C
uCu
Note"
,
"version"
:
"1.
0
.0"
,
"description"
:
"
Quick note-taking extension - Highlight text and save to CuCu Note
"
,
"name"
:
"C
anifa
Note"
,
"version"
:
"1.
1
.0"
,
"description"
:
"
Ghi chú nhanh từ web — Bôi đen, nhấn Space, lưu tự động
"
,
"permissions"
:
[
"activeTab"
,
"storage"
,
...
...
extension/src/background/service-worker.ts
View file @
25b29d27
/**
* Background Service Worker
*
Nhiệm vụ:
* 1. Handle SYNC_AUTH
từ content script (auto-sync Clerk token)
* 2. Handle SAVE_NOTE
từ content script (gọi API save memo)
* 3. Handle REFRESH_TOKEN
từ popup (refresh Clerk JWT trước khi gọi API)
* Background Service Worker
— Canifa Note
*
* 1. Handle SYNC_AUTH
from content script (auto-sync Memos token)
* 2. Handle SAVE_NOTE
from content script (call API save memo)
* 3. Handle REFRESH_TOKEN
from popup
*/
import
{
createMemo
}
from
'../shared/api-client'
;
/**
* Grab fresh Clerk token from ANY OpenNotion tab
* Scans all open tabs, finds OpenNotion, injects script to get token
* Grab fresh Memos token from any Canifa Note tab
*/
async
function
grabFreshToken
():
Promise
<
string
|
null
>
{
try
{
...
...
@@ -19,9 +18,9 @@ async function grabFreshToken(): Promise<string | null> {
const
url
=
t
.
url
||
''
;
return
(
url
.
includes
(
'172.16.2.210'
)
||
url
.
includes
(
'localhost:
3001'
)
||
url
.
includes
(
'
opennotion'
)
||
url
.
includes
(
'c
ucunote'
)
url
.
includes
(
'localhost:
5230'
)
||
url
.
includes
(
'
cucunote'
)
||
url
.
includes
(
'c
anifa'
)
);
});
...
...
@@ -31,28 +30,20 @@ async function grabFreshToken(): Promise<string | null> {
const
results
=
await
chrome
.
scripting
.
executeScript
({
target
:
{
tabId
:
tab
.
id
},
world
:
'MAIN'
,
func
:
async
()
=>
{
func
:
()
=>
{
try
{
// 1.
Try Memos cookie
// 1.
Memos access-token cookie
const
match
=
document
.
cookie
.
match
(
/
(?:
^|;
)
memos
\.
access-token=
([^
;
]
+
)
/
);
if
(
match
&&
match
[
1
])
return
match
[
1
];
// 2.
Try localStorage
// 2.
localStorage
for
(
let
i
=
0
;
i
<
localStorage
.
length
;
i
++
)
{
const
key
=
localStorage
.
key
(
i
);
if
(
key
&&
(
key
.
includes
(
'
token'
)
||
key
.
includes
(
'session'
)))
{
if
(
key
&&
(
key
.
includes
(
'
access_token'
)
||
key
.
includes
(
'token'
)))
{
const
val
=
localStorage
.
getItem
(
key
);
if
(
val
&&
typeof
val
===
'string'
&&
val
.
length
>
50
&&
!
val
.
startsWith
(
'{'
))
{
return
val
;
if
(
val
&&
val
.
length
>
20
&&
!
val
.
startsWith
(
'{'
))
return
val
;
}
}
}
// 3. Try Clerk
const
clerk
=
(
window
as
any
).
Clerk
;
if
(
clerk
?.
session
?.
getToken
)
{
return
await
clerk
.
session
.
getToken
();
}
}
catch
{
/* ignore */
}
return
null
;
},
...
...
@@ -60,14 +51,14 @@ async function grabFreshToken(): Promise<string | null> {
const
token
=
results
[
0
]?.
result
;
if
(
token
)
{
await
chrome
.
storage
.
local
.
set
({
clerkSessionToken
:
token
,
clerkTokenSyncedAt
:
Date
.
now
(),
memosAccessToken
:
token
,
tokenSyncedAt
:
Date
.
now
(),
});
console
.
log
(
'[C
uCu BG] ✅ Fresh token from tab'
,
tab
.
id
,
`(
${
token
.
length
}
chars)`
);
console
.
log
(
'[C
anifa Note] ✅ Token synced from tab'
,
tab
.
id
);
return
token
;
}
}
catch
{
// T
his tab didn't work, try next
// T
ry next tab
}
}
}
catch
{
...
...
@@ -79,62 +70,49 @@ async function grabFreshToken(): Promise<string | null> {
chrome
.
runtime
.
onMessage
.
addListener
((
message
,
_sender
,
sendResponse
)
=>
{
// ============ AUTO-SYNC AUTH TOKEN ============
if
(
message
.
type
===
'SYNC_AUTH'
)
{
const
{
clerkSessionToken
}
=
message
.
data
||
{};
if
(
clerkSessionToken
)
{
const
token
=
message
.
data
?.
memosAccessToken
||
message
.
data
?.
clerkSessionToken
;
if
(
token
)
{
chrome
.
storage
.
local
.
set
({
clerkSessionToken
,
clerkTokenSyncedAt
:
Date
.
now
(),
memosAccessToken
:
token
,
tokenSyncedAt
:
Date
.
now
(),
});
console
.
log
(
'[C
uCu BG] ✅ Clerk token auto-synced from content script'
);
console
.
log
(
'[C
anifa Note] ✅ Token auto-synced from content script'
);
}
sendResponse
({
success
:
true
});
return
false
;
// synchronous response
return
false
;
}
// ============ REFRESH TOKEN
(popup requests fresh token) ============
// ============ REFRESH TOKEN
============
if
(
message
.
type
===
'REFRESH_TOKEN'
)
{
// Scan ALL OpenNotion tabs to get a fresh Clerk JWT
grabFreshToken
()
.
then
((
token
)
=>
{
if
(
token
)
{
sendResponse
({
success
:
true
,
token
});
}
else
{
sendResponse
({
success
:
false
,
reason
:
'no_
opennotion_tab'
});
sendResponse
({
success
:
false
,
reason
:
'no_
canifa_note_tab'
});
}
})
.
catch
((
err
)
=>
{
console
.
log
(
'[CuCu BG] ❌ Token refresh failed:'
,
err
?.
message
);
sendResponse
({
success
:
false
,
reason
:
err
?.
message
});
});
return
true
;
// keep channel open for async
return
true
;
}
// ============ SHOW NOTE FORM ============
if
(
message
.
type
===
'SHOW_NOTE_FORM'
)
{
// Mở popup với note form
chrome
.
action
.
openPopup
();
// Lưu data vào storage để popup có thể lấy
chrome
.
storage
.
local
.
set
({
pendingNote
:
message
.
data
,
});
chrome
.
storage
.
local
.
set
({
pendingNote
:
message
.
data
});
sendResponse
({
success
:
true
});
}
// ============ SAVE NOTE ============
else
if
(
message
.
type
===
'SAVE_NOTE'
)
{
// Auto save note ngay lập tức
const
{
text
,
url
,
title
}
=
message
.
data
;
// Parse tags từ URL
const
domain
=
new
URL
(
url
).
hostname
.
replace
(
'www.'
,
''
);
const
tagList
=
[
domain
,
'web-highlight'
];
// 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
,
...
...
@@ -149,8 +127,8 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
sendResponse
({
success
:
false
,
error
:
error
.
message
});
});
return
true
;
// Keep channel open for async
return
true
;
}
return
true
;
// Keep channel open for async response
return
true
;
});
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
*
to get fresh JWTs for API calls. Does NOT close the popup after save.
*
Clean, minimal note form. Always visible.
*
Uses Memos access token for API calls.
*/
import
{
useState
}
from
'react'
;
import
{
createMemoWithToken
}
from
'../shared/api-client'
;
import
{
TagSelector
}
from
'./TagSelector'
;
import
{
WorkspaceSelector
}
from
'./WorkspaceSelector'
;
interface
NoteFormProps
{
initialText
?:
string
;
...
...
@@ -29,7 +28,6 @@ export function NoteForm({
}:
NoteFormProps
)
{
const
[
text
,
setText
]
=
useState
(
initialText
);
const
[
tags
,
setTags
]
=
useState
<
string
[]
>
([]);
const
[
workspace
,
setWorkspace
]
=
useState
(
''
);
const
[
visibility
,
setVisibility
]
=
useState
<
'PRIVATE'
|
'PUBLIC'
>
(
'PRIVATE'
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
saved
,
setSaved
]
=
useState
(
false
);
...
...
@@ -37,7 +35,7 @@ export function NoteForm({
const
handleSave
=
async
()
=>
{
if
(
!
text
.
trim
())
{
setError
(
'
Note cannot be empty'
);
setError
(
'
Chưa nhập nội dung ghi chú'
);
return
;
}
...
...
@@ -46,42 +44,33 @@ export function NoteForm({
setSaved
(
false
);
try
{
// Get FRESH token from Clerk
const
token
=
await
getToken
();
if
(
!
token
)
{
setError
(
'
Not authenticated. Please sign in again.'
);
setError
(
'
Chưa đồng bộ. Mở Canifa Note trên trình duyệt và đăng nhập.'
);
setLoading
(
false
);
return
;
}
// Build content with source info
let
content
=
text
;
if
(
initialUrl
&&
initialUrl
!==
'about:blank'
)
{
content
+=
`\n\n---\nSource: [
${
initialTitle
||
initialUrl
}
](
${
initialUrl
}
)`
;
}
// Call API with fresh token
await
createMemoWithToken
(
token
,
{
content
,
tags
,
visibility
,
});
// Success! Show message but KEEP popup open
setLoading
(
false
);
setSaved
(
true
);
// Clear form for next note
setText
(
''
);
setTags
([]);
setError
(
null
);
// Auto-hide success after 3s
setTimeout
(()
=>
setSaved
(
false
),
3000
);
if
(
onSave
)
onSave
();
}
catch
(
err
)
{
setError
(
err
instanceof
Error
?
err
.
message
:
'
Failed to save note'
);
setError
(
err
instanceof
Error
?
err
.
message
:
'
Lưu thất bại'
);
setLoading
(
false
);
}
};
...
...
@@ -89,54 +78,41 @@ export function NoteForm({
return
(
<
div
className=
"note-form"
>
<
div
className=
"content"
>
{
/* Success
Banner */
}
{
/* Success
*/
}
{
saved
&&
(
<
div
className=
"badge badge-success"
style=
{
{
textAlign
:
'center'
,
padding
:
'10px'
,
fontSize
:
'14px'
,
marginBottom
:
'8px'
,
animation
:
'fadeIn 0.3s ease'
}
}
>
✅ Saved to CuCu Note!
<
div
className=
"success"
style=
{
{
textAlign
:
'center'
,
animation
:
'fadeIn 0.2s ease'
}
}
>
✓ Đã lưu vào Canifa Note
</
div
>
)
}
{
/* Text
Area */
}
{
/* Text
area */
}
<
textarea
className=
"note-textarea"
placeholder=
"
Any thoughts..."
placeholder=
"
Ghi chú..."
value=
{
text
}
onChange=
{
(
e
)
=>
setText
(
e
.
target
.
value
)
}
rows=
{
4
}
autoFocus
/>
{
/* Source
Info */
}
{
/* Source
*/
}
{
initialUrl
&&
initialUrl
!==
'about:blank'
&&
(
<
div
className=
"source-info"
style=
{
{
fontSize
:
'12px'
,
color
:
'var(--text-secondary)'
,
padding
:
'6px 10px'
,
backgroundColor
:
'var(--surface)'
,
borderRadius
:
'8px'
,
marginTop
:
'6px'
,
overflow
:
'hidden'
,
textOverflow
:
'ellipsis'
,
whiteSpace
:
'nowrap'
,
}
}
>
🔗
{
initialTitle
||
initialUrl
}
<
div
className=
"source-info"
>
<
svg
className=
"source-icon"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2"
strokeLinecap=
"round"
strokeLinejoin=
"round"
>
<
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
>
<
span
style=
{
{
overflow
:
'hidden'
,
textOverflow
:
'ellipsis'
,
whiteSpace
:
'nowrap'
}
}
>
{
initialTitle
||
initialUrl
}
</
span
>
</
div
>
)
}
{
/* Tag
Selector */
}
<
div
style=
{
{
marginTop
:
'1
2px'
}
}
>
{
/* Tag
s */
}
<
div
style=
{
{
marginTop
:
'1
0px'
}
}
>
<
TagSelector
selectedTags=
{
tags
}
onChange=
{
setTags
}
/>
</
div
>
{
/* Workspace Selector */
}
<
WorkspaceSelector
value=
{
workspace
}
onChange=
{
setWorkspace
}
/>
{
/* Error */
}
{
error
&&
<
div
className=
"error"
>
{
error
}
</
div
>
}
</
div
>
...
...
@@ -146,12 +122,21 @@ export function NoteForm({
<
div
className=
"action-bar-left"
>
<
button
className=
"visibility-selector"
onClick=
{
()
=>
setVisibility
(
visibility
===
'PRIVATE'
?
'PUBLIC'
:
'PRIVATE'
)
}
title=
{
`Click to toggle: ${visibility}`
}
onClick=
{
()
=>
setVisibility
(
visibility
===
'PRIVATE'
?
'PUBLIC'
:
'PRIVATE'
)
}
title=
{
`Toggle: ${visibility}`
}
>
{
visibility
===
'PRIVATE'
?
'🔒'
:
'🌐'
}{
' '
}
{
visibility
===
'PRIVATE'
?
(
<
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"
/>
</
svg
>
)
:
(
<
svg
width=
"12"
height=
"12"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2"
strokeLinecap=
"round"
strokeLinejoin=
"round"
>
<
circle
cx=
"12"
cy=
"12"
r=
"10"
/>
<
line
x1=
"2"
y1=
"12"
x2=
"22"
y2=
"12"
/>
<
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"
/>
</
svg
>
)
}
{
visibility
===
'PRIVATE'
?
'Private'
:
'Public'
}
</
button
>
</
div
>
...
...
@@ -169,10 +154,10 @@ export function NoteForm({
disabled=
{
loading
||
!
text
.
trim
()
}
>
{
saved
?
(
'
✅ Saved!'
'
✓ Saved'
)
:
loading
?
(
<>
<
span
className=
"spinner"
/>
Saving...
<
span
className=
"spinner"
style=
{
{
width
:
12
,
height
:
12
}
}
/>
Saving...
</>
)
:
(
'Save'
...
...
extension/src/components/Settings.tsx
View file @
25b29d27
/**
* Settings Component
* Server URL config, auth sync, connection test
* Settings Component — shadcn/ui styled
*/
import
{
useState
,
useEffect
}
from
'react'
;
...
...
@@ -8,7 +7,6 @@ import {
getApiBaseUrl
,
setApiBaseUrl
,
getAuthStatus
,
syncClerkTokenFromPage
,
testConnection
,
}
from
'../shared/api-client'
;
...
...
@@ -19,7 +17,6 @@ export function Settings() {
tokenLength
:
0
,
});
const
[
testResult
,
setTestResult
]
=
useState
<
{
ok
:
boolean
;
message
:
string
}
|
null
>
(
null
);
const
[
syncing
,
setSyncing
]
=
useState
(
false
);
const
[
testing
,
setTesting
]
=
useState
(
false
);
const
[
saved
,
setSaved
]
=
useState
(
false
);
...
...
@@ -35,7 +32,7 @@ export function Settings() {
};
const
handleSaveUrl
=
async
()
=>
{
const
cleaned
=
serverUrl
.
trim
().
replace
(
/
\/
+$/
,
''
);
// remove trailing slashes
const
cleaned
=
serverUrl
.
trim
().
replace
(
/
\/
+$/
,
''
);
await
setApiBaseUrl
(
cleaned
);
setServerUrl
(
cleaned
);
setSaved
(
true
);
...
...
@@ -43,21 +40,6 @@ export function Settings() {
setTimeout
(()
=>
setSaved
(
false
),
2000
);
};
const
handleSyncAuth
=
async
()
=>
{
setSyncing
(
true
);
try
{
const
token
=
await
syncClerkTokenFromPage
();
if
(
token
)
{
setAuthStatus
({
isConnected
:
true
,
tokenLength
:
token
.
length
});
}
else
{
setAuthStatus
({
isConnected
:
false
,
tokenLength
:
0
});
}
}
catch
{
setAuthStatus
({
isConnected
:
false
,
tokenLength
:
0
});
}
setSyncing
(
false
);
};
const
handleTestConnection
=
async
()
=>
{
setTesting
(
true
);
setTestResult
(
null
);
...
...
@@ -68,10 +50,9 @@ export function Settings() {
return
(
<
div
className=
"content"
>
{
/* Server
URL */
}
{
/* Server
*/
}
<
div
className=
"settings-section"
>
<
div
className=
"settings-title"
>
🌐 Server
</
div
>
<
div
className=
"settings-title"
>
Server
</
div
>
<
div
className=
"form-group"
>
<
label
className=
"label"
>
API URL
</
label
>
<
div
className=
"settings-input-group"
>
...
...
@@ -84,18 +65,16 @@ export function Settings() {
setSaved
(
false
);
setTestResult
(
null
);
}
}
placeholder=
"http
s://your-domain.com"
placeholder=
"http
://172.16.2.210:5230"
/>
<
button
className=
"btn btn-primary btn-sm"
onClick=
{
handleSaveUrl
}
>
{
saved
?
'✓'
:
'Save'
}
</
button
>
</
div
>
<
div
className=
"label-hint"
>
Backend server URL (without /api/v1)
</
div
>
<
div
className=
"label-hint"
>
Backend server URL (without /api/v1)
</
div
>
</
div
>
<
div
style=
{
{
marginTop
:
'
8px'
}
}
>
<
div
style=
{
{
marginTop
:
'
6px'
}
}
>
<
button
className=
"btn btn-secondary btn-sm btn-full"
onClick=
{
handleTestConnection
}
...
...
@@ -103,78 +82,45 @@ export function Settings() {
>
{
testing
?
(
<>
<
span
className=
"spinner"
/>
Testing...
<
span
className=
"spinner"
style=
{
{
width
:
12
,
height
:
12
}
}
/>
Testing...
</>
)
:
(
'
🔌 Test Connection'
'
Test Connection'
)
}
</
button
>
</
div
>
{
testResult
&&
(
<
div
className=
{
testResult
.
ok
?
'success'
:
'error'
}
style=
{
{
marginTop
:
'8px'
}
}
>
<
div
className=
{
testResult
.
ok
?
'success'
:
'error'
}
style=
{
{
marginTop
:
'6px'
}
}
>
{
testResult
.
message
}
</
div
>
)
}
</
div
>
{
/* Auth
*/
}
{
/* Auth
Status */
}
<
div
className=
"settings-section"
>
<
div
className=
"settings-title"
>
🔑 Authentication
</
div
>
<
div
className=
"settings-title"
>
Authentication
</
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
className=
"settings-item-label"
>
Memos Access Token
</
div
>
<
div
className=
"settings-item-desc"
>
Auto-synced from Canifa Note tab
</
div
>
</
div
>
<
span
className=
{
`badge ${authStatus.isConnected ? 'badge-success' : 'badge-error'}`
}
>
<
span
className=
{
`status-dot ${authStatus.isConnected ? 'connected' : 'disconnected'}`
}
/>
<
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
<
div
className=
"settings-title"
>
About
</
div
>
<
div
style=
{
{
fontSize
:
'12px'
,
color
:
'var(--muted-foreground)'
,
lineHeight
:
'1.6'
}
}
>
<
strong
>
Canifa Note
</
strong
>
v1.0.0
<
br
/>
Quick note-taking from any web page.
Ghi chú nhanh từ bất kỳ trang web nào.
<
br
/>
Highlight text → Press Space → Auto save!
Bôi đen → Nhấn Space → Lưu tự động
</
div
>
</
div
>
</
div
>
...
...
extension/src/components/TagSelector.tsx
View file @
25b29d27
/**
* Tag Selector Component
* Chip-based tag selector with server-fetched tags + inline new tag input
* Tag Selector — shadcn/ui badge chips
*/
import
{
useState
,
useEffect
,
useRef
}
from
'react'
;
...
...
@@ -35,11 +34,10 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) {
fetchTags
(),
getRecentTags
(),
]);
// Merge: recent first, then server tags
const
merged
=
[...
new
Set
([...
recentTags
,
...
serverTags
])];
setAvailableTags
(
merged
);
}
catch
{
// Fallback
to empty
// Fallback
}
setLoading
(
false
);
};
...
...
@@ -49,7 +47,6 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) {
onChange
(
selectedTags
.
filter
((
t
)
=>
t
!==
tag
));
}
else
{
onChange
([...
selectedTags
,
tag
]);
// Save to recent
saveRecentTags
([
tag
,
...
selectedTags
]);
}
};
...
...
@@ -77,8 +74,7 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) {
}
};
// Show max 12 tags to keep UI compact
const
displayTags
=
availableTags
.
slice
(
0
,
12
);
const
displayTags
=
availableTags
.
slice
(
0
,
10
);
return
(
<
div
className=
"form-group"
>
...
...
@@ -86,7 +82,7 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) {
<
div
className=
"tag-chips"
>
{
loading
?
(
<
span
style=
{
{
fontSize
:
'11px'
,
color
:
'var(--muted-foreground)'
}
}
>
Loading
tags...
Loading
...
</
span
>
)
:
(
<>
...
...
@@ -113,7 +109,7 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) {
onChange=
{
(
e
)
=>
setNewTag
(
e
.
target
.
value
)
}
onKeyDown=
{
handleKeyDown
}
onBlur=
{
addNewTag
}
placeholder=
"
new tag"
placeholder=
"
tag"
/>
</
div
>
)
:
(
...
...
@@ -128,11 +124,6 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) {
</>
)
}
</
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
*
Nhiệm vụ:
* 1. Auto-sync
Clerk auth token khi đang ở trang OpenNotion
* 2. Detect text selection → Space
key → Auto save → Toast notification
* Content Script
— Canifa Note
*
* 1. Auto-sync
Memos access token when on Canifa Note pages
* 2. Detect text selection → Space
/Enter → Auto save with toast
*/
let
selectedText
=
''
;
let
selectedUrl
=
''
;
let
selectedTitle
=
''
;
// ========== AUTO
CLERK TOKEN SYNC ==========
// ========== AUTO
MEMOS TOKEN SYNC ==========
/**
* Detect nếu đang ở trang OpenNotion frontend → auto lấy Clerk token
* Clerk SDK loads async, nên cần poll chờ nó sẵn sàng
*/
async
function
tryExtractClerkToken
():
Promise
<
string
|
null
>
{
const
maxRetries
=
5
;
const
retryDelay
=
800
;
// ms
async
function
tryExtractToken
():
Promise
<
string
|
null
>
{
const
maxRetries
=
3
;
const
retryDelay
=
500
;
for
(
let
attempt
=
0
;
attempt
<
maxRetries
;
attempt
++
)
{
try
{
// 1.
Try Memos cookie
// 1.
Memos cookie
const
match
=
document
.
cookie
.
match
(
/
(?:
^|;
)
memos
\.
access-token=
([^
;
]
+
)
/
);
if
(
match
&&
match
[
1
])
return
match
[
1
];
// 2.
Try Memos API token from localStorage (if any)
// 2.
localStorage
for
(
let
i
=
0
;
i
<
localStorage
.
length
;
i
++
)
{
const
key
=
localStorage
.
key
(
i
);
if
(
key
&&
(
key
.
includes
(
'
token'
)
||
key
.
includes
(
'session'
)))
{
if
(
key
&&
(
key
.
includes
(
'
access_token'
)
||
key
.
includes
(
'token'
)))
{
const
val
=
localStorage
.
getItem
(
key
);
if
(
val
&&
typeof
val
===
'string'
&&
val
.
length
>
50
&&
!
val
.
startsWith
(
'{'
))
{
// Return if looks like a JWT or long token
if
(
val
&&
typeof
val
===
'string'
&&
val
.
length
>
20
&&
!
val
.
startsWith
(
'{'
))
{
return
val
;
}
}
}
// 3. Try Clerk token
const
clerk
=
(
window
as
any
).
Clerk
;
// Skip if Clerk loaded but user not signed in
if
(
clerk
&&
!
clerk
.
user
)
return
null
;
if
(
clerk
?.
session
?.
getToken
)
{
const
token
=
await
clerk
.
session
.
getToken
();
if
(
token
)
return
token
;
}
}
catch
{
//
Clerk/Memos chưa ready, thử lại
//
Not ready yet
}
if
(
attempt
<
maxRetries
-
1
)
{
...
...
@@ -56,50 +42,43 @@ async function tryExtractClerkToken(): Promise<string | null> {
return
null
;
}
/**
* Auto-sync: nếu đang ở trang OpenNotion, lấy Clerk token rồi gửi về background
*/
async
function
autoSyncClerkToken
()
{
// Chỉ chạy trên domain OpenNotion (kiểm tra cả localhost dev và production)
async
function
autoSyncToken
()
{
const
hostname
=
window
.
location
.
hostname
;
const
is
OpenNotionDomain
=
const
is
CanifaDomain
=
hostname
===
'172.16.2.210'
||
hostname
===
'localhost'
||
hostname
.
includes
(
'
opennotion'
)
||
hostname
.
includes
(
'
canifa'
)
||
hostname
.
includes
(
'cucunote'
)
||
hostname
.
includes
(
'cucu-note'
);
if
(
!
is
OpenNotionDomain
)
return
;
if
(
!
is
CanifaDomain
)
return
;
const
token
=
await
tryExtract
ClerkToken
();
const
token
=
await
tryExtract
Token
();
if
(
token
)
{
// Gửi token về background service worker để lưu vào chrome.storage.local
chrome
.
runtime
.
sendMessage
({
type
:
'SYNC_AUTH'
,
data
:
{
clerkSessionToken
:
token
},
data
:
{
memosAccessToken
:
token
},
});
}
}
// Chạy auto-sync khi content script load
autoSyncClerkToken
();
autoSyncToken
();
//
Cũng lắng nghe request từ popup để sync on-demand
//
On-demand token request
chrome
.
runtime
.
onMessage
.
addListener
((
message
,
_sender
,
sendResponse
)
=>
{
if
(
message
.
type
===
'GET_
CLERK_TOKEN'
)
{
tryExtract
ClerkToken
().
then
((
token
)
=>
{
if
(
message
.
type
===
'GET_
TOKEN'
)
{
tryExtract
Token
().
then
((
token
)
=>
{
sendResponse
({
token
});
});
return
true
;
// keep channel open for async
return
true
;
}
});
// Listen khi user bôi đen text
// ========== TEXT SELECTION → AUTO SAVE ==========
document
.
addEventListener
(
'mouseup'
,
handleTextSelection
);
document
.
addEventListener
(
'keydown'
,
async
(
e
)
=>
{
// Nếu nhấn Space hoặc Enter sau khi đã bôi đen text → tự động lưu ngay
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
;
if
(
!
target
||
(
target
.
tagName
!==
'INPUT'
&&
target
.
tagName
!==
'TEXTAREA'
&&
!
target
.
isContentEditable
))
{
e
.
preventDefault
();
...
...
@@ -113,10 +92,8 @@ document.addEventListener('keyup', handleTextSelection);
function
handleTextSelection
()
{
try
{
// Delay một chút để đảm bảo selection đã hoàn tất
setTimeout
(()
=>
{
const
selection
=
window
.
getSelection
();
if
(
!
selection
||
selection
.
rangeCount
===
0
)
{
selectedText
=
''
;
removeQuickHint
();
...
...
@@ -124,23 +101,19 @@ function handleTextSelection() {
}
const
text
=
selection
.
toString
().
trim
();
if
(
text
.
length
===
0
)
{
selectedText
=
''
;
removeQuickHint
();
return
;
}
// Lưu text đã chọn + metadata
selectedText
=
text
;
selectedUrl
=
window
.
location
.
href
;
selectedTitle
=
document
.
title
;
// Hiện hint nhỏ để user biết có thể nhấn Space/Enter
showQuickHint
();
},
50
);
}
catch
{
// Silently ignore
selection errors
// Silently ignore
}
}
...
...
@@ -148,10 +121,8 @@ async function handleAutoSave() {
if
(
!
selectedText
)
return
;
try
{
// Show loading toast
showToast
(
'Đang lưu...'
,
'loading'
);
// Gửi message đến background để save
chrome
.
runtime
.
sendMessage
({
type
:
'SAVE_NOTE'
,
data
:
{
...
...
@@ -161,66 +132,59 @@ async function handleAutoSave() {
},
},
(
response
)
=>
{
if
(
chrome
.
runtime
.
lastError
)
{
showToast
(
`
❌ Lỗi:
${
chrome
.
runtime
.
lastError
.
message
||
'Extension error'
}
`
,
'error'
);
showToast
(
`
Lỗi:
${
chrome
.
runtime
.
lastError
.
message
||
'Extension error'
}
`
,
'error'
);
return
;
}
if
(
response
?.
success
)
{
showToast
(
'✅ Đã lưu vào CuCu Note!'
,
'success'
);
// Clear selection
showToast
(
'Đã lưu vào Canifa Note'
,
'success'
);
window
.
getSelection
()?.
removeAllRanges
();
selectedText
=
''
;
removeQuickHint
();
}
else
{
const
reason
=
response
?.
error
||
'Không thể lưu
note'
;
showToast
(
`❌
${
reason
}
`
,
'error'
);
const
reason
=
response
?.
error
||
'Không thể lưu
'
;
showToast
(
reason
,
'error'
);
}
});
}
catch
(
err
:
any
)
{
showToast
(
`❌
${
err
?.
message
||
'Lỗi khi lưu note'
}
`, 'error');
showToast
(
err
?.
message
||
'Lỗi khi lưu'
,
'error'
);
}
}
// ========== UI: Quick Hint ==========
function
showQuickHint
()
{
// Hiện hint nhỏ "Nhấn Space để lưu" với gradient đẹp
removeQuickHint
();
const
hint
=
document
.
createElement
(
'div'
);
hint
.
id
=
'cucu-quick-hint'
;
hint.textContent = '
💡 Nhấn Space hoặc Enter để lưu nhanh';
hint
.
textContent
=
'
Space hoặc Enter để lưu nhanh'
;
// Gradient màu đẹp
hint
.
style
.
cssText
=
`
position: fixed;
bottom: 20px;
right: 20px;
background
:
linear
-
gradient
(
135
deg
,
#
a0845c
0
%
,
#
8
b6914
100
%
);
color
:
white
;
padding
:
1
2
px
20
px
;
background:
hsl(240 10% 3.9%);
color:
hsl(0 0% 98%);
padding: 1
0px 16px;
border-radius: 8px;
font
-
size
:
1
4
px
;
font-size: 1
3px;
font-weight: 500;
box
-
shadow
:
0
4
px
12
px
rgba
(
0
,
0
,
0
,
0.15
);
border: 1px solid hsl(240 3.7% 15.9%);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
animation
:
cucuSlideUp
0.
3
s
ease
-
out
;
animation: cucuSlideUp 0.
2s ease-out;
pointer-events: none;
`
;
// Inject animation CSS
if
(
!
document
.
getElementById
(
'cucu-hint-style'
))
{
const
style
=
document
.
createElement
(
'style'
);
style
.
id
=
'cucu-hint-style'
;
style
.
textContent
=
`
@keyframes cucuSlideUp {
from
{
opacity
:
0
;
transform
:
translateY
(
20
px
);
}
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
`
;
document
.
head
.
appendChild
(
style
);
...
...
@@ -228,112 +192,84 @@ function showQuickHint() {
try
{
document
.
body
.
appendChild
(
hint
);
} catch
(e) {
}
catch
{
document
.
documentElement
.
appendChild
(
hint
);
}
// Auto ẩn sau 3 giây
setTimeout(() => {
removeQuickHint();
}, 3000);
setTimeout
(()
=>
removeQuickHint
(),
3000
);
}
function
removeQuickHint
()
{
const
hint
=
document
.
getElementById
(
'cucu-quick-hint'
);
if
(
hint
)
{
try {
hint.remove();
} catch (e) {
// Ignore
}
try
{
hint
.
remove
();
}
catch
{
/* ignore */
}
}
}
// ========== UI: Toast ==========
function
showToast
(
message
:
string
,
type
:
'success'
|
'error'
|
'loading'
=
'success'
)
{
// Remove toast cũ nếu có
const
oldToast
=
document
.
getElementById
(
'cucu-toast'
);
if
(
oldToast
)
{
try {
oldToast.remove();
} catch (e) {
// Ignore
}
try
{
oldToast
.
remove
();
}
catch
{
/* ignore */
}
}
const
toast
=
document
.
createElement
(
'div'
);
toast
.
id
=
'cucu-toast'
;
toast.textContent = message;
// Màu sắc đẹp theo type (gradient)
const
icons
=
{
success
:
'✓'
,
error
:
'✕'
,
loading
:
'⟳'
};
toast
.
textContent
=
`
${
icons
[
type
]}
${
message
}
`
;
const
colors
=
{
success: '
linear-gradient(135deg, #10b981 0%, #059669 100%)',
error: '
linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
loading: '
linear-gradient(135deg, #a0845c 0%, #8b6914 100%)',
success
:
'
hsl(142 71% 45%)'
,
error
:
'
hsl(0 72% 51%)'
,
loading
:
'
hsl(240 5% 64.9%)'
,
};
toast
.
style
.
cssText
=
`
position: fixed;
top: 20px;
right: 20px;
background
:
$
{
colors
[
type
]};
color
:
white
;
padding
:
1
6
px
24
px
;
border
-
radius
:
12
px
;
font
-
size
:
1
5
px
;
background:
hsl(240 10% 3.9%);
color:
${
colors
[
type
]}
;
padding: 1
2px 18px;
border-radius:
8px;
font-size: 1
3px;
font-weight: 500;
box
-
shadow
:
0
8
px
24
px
rgba
(
0
,
0
,
0
,
0.2
);
border: 1px solid hsl(240 3.7% 15.9%);
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
animation
:
cucuToastSlide
0.
4
s
ease
-
out
;
max
-
width
:
3
50
px
;
animation: cucuToastSlide 0.
25s ease-out;
max-width: 3
20px;
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
);
}
from { opacity: 0; transform: translateX(100%); }
to { opacity: 1; transform: translateX(0); }
}
`
;
try {
document.head.appendChild(style);
} catch (e) {
// Ignore
}
try
{
document
.
head
.
appendChild
(
style
);
}
catch
{
/* ignore */
}
}
try
{
document
.
body
.
appendChild
(
toast
);
} catch (e) {
try {
document.documentElement.appendChild(toast);
} catch (e2) {
return;
}
}
catch
{
try
{
document
.
documentElement
.
appendChild
(
toast
);
}
catch
{
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';
toast
.
style
.
animation
=
'cucuToastSlide 0.
2s ease-out reverse'
;
setTimeout
(()
=>
{
try {
toast.remove();
} catch (e) {
// Ignore
}
}, 300);
try
{
toast
.
remove
();
}
catch
{
/* ignore */
}
},
200
);
}
},
3000
);
}
...
...
extension/src/popup/popup.css
View file @
25b29d27
/* ================================================================
C
uCu Note Extension — Premium Design System
Modern, rounded, glassmorphic UI with warm brown palette
C
anifa Note Extension — shadcn/ui Design System
Dark-first, token-driven, Canifa red accent
================================================================ */
/* ----
Light Theme (Paper — warm brown/amber) ---- */
/* ----
Design Tokens ---- */
:root
{
--background
:
oklch
(
0.95
0.015
75
);
--foreground
:
oklch
(
0.25
0.02
65
);
--card
:
oklch
(
0.98
0.008
80
);
--card-foreground
:
oklch
(
0.22
0.015
68
);
--popover
:
oklch
(
0.98
0.008
80
);
--popover-foreground
:
oklch
(
0.25
0.02
65
);
--primary
:
oklch
(
0.45
0.08
45
);
--primary-foreground
:
oklch
(
0.98
0.008
80
);
--secondary
:
oklch
(
0.92
0.025
70
);
--secondary-foreground
:
oklch
(
0.35
0.03
60
);
--muted
:
oklch
(
0.9
0.025
75
);
--muted-foreground
:
oklch
(
0.5
0.02
68
);
--accent
:
oklch
(
0.88
0.035
55
);
--accent-foreground
:
oklch
(
0.25
0.02
65
);
--destructive
:
oklch
(
0.48
0.15
25
);
--border
:
oklch
(
0.88
0.018
72
);
--input
:
oklch
(
0.8
0.03
75
);
--ring
:
oklch
(
0.45
0.08
45
);
--radius
:
12px
;
--success
:
oklch
(
0.6
0.15
145
);
--warning
:
oklch
(
0.7
0.12
75
);
--shadow-sm
:
0
1px
2px
oklch
(
0
0
0
/
0.04
);
--shadow-md
:
0
4px
12px
oklch
(
0
0
0
/
0.06
);
--shadow-lg
:
0
8px
24px
oklch
(
0
0
0
/
0.08
);
--font-sans
:
'Inter'
,
ui-sans-serif
,
system-ui
,
-apple-system
,
BlinkMacSystemFont
,
"Segoe UI"
,
Roboto
,
sans-serif
;
}
/* ---- Dark Theme ---- */
[
data-theme
=
"dark"
]
{
--background
:
oklch
(
0.16
0.008
60
);
--foreground
:
oklch
(
0.9
0.012
75
);
--card
:
oklch
(
0.20
0.01
62
);
--card-foreground
:
oklch
(
0.9
0.012
75
);
--popover
:
oklch
(
0.20
0.01
62
);
--popover-foreground
:
oklch
(
0.88
0.01
72
);
--primary
:
oklch
(
0.65
0.1
45
);
--primary-foreground
:
oklch
(
0.15
0.008
60
);
--secondary
:
oklch
(
0.26
0.012
65
);
--secondary-foreground
:
oklch
(
0.85
0.01
72
);
--muted
:
oklch
(
0.23
0.01
62
);
--muted-foreground
:
oklch
(
0.6
0.015
70
);
--accent
:
oklch
(
0.28
0.015
55
);
--accent-foreground
:
oklch
(
0.82
0.012
68
);
--destructive
:
oklch
(
0.55
0.1
25
);
--border
:
oklch
(
0.30
0.012
62
);
--input
:
oklch
(
0.35
0.015
65
);
--ring
:
oklch
(
0.65
0.1
45
);
--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
);
--shadow-lg
:
0
8px
24px
oklch
(
0
0
0
/
0.25
);
/* Surface */
--background
:
hsl
(
240
10%
3.9%
);
--foreground
:
hsl
(
0
0%
98%
);
--card
:
hsl
(
240
10%
3.9%
);
--card-foreground
:
hsl
(
0
0%
98%
);
--popover
:
hsl
(
240
10%
3.9%
);
--popover-foreground
:
hsl
(
0
0%
98%
);
/* Canifa Red Accent */
--primary
:
hsl
(
2
78%
53%
);
--primary-foreground
:
hsl
(
0
0%
100%
);
/* Secondary / Muted */
--secondary
:
hsl
(
240
3.7%
15.9%
);
--secondary-foreground
:
hsl
(
0
0%
98%
);
--muted
:
hsl
(
240
3.7%
15.9%
);
--muted-foreground
:
hsl
(
240
5%
64.9%
);
--accent
:
hsl
(
240
3.7%
15.9%
);
--accent-foreground
:
hsl
(
0
0%
98%
);
/* Feedback */
--destructive
:
hsl
(
0
62.8%
30.6%
);
--destructive-foreground
:
hsl
(
0
0%
98%
);
--success
:
hsl
(
142
71%
45%
);
--success-foreground
:
hsl
(
0
0%
100%
);
--warning
:
hsl
(
48
96%
53%
);
/* Border / Input / Ring */
--border
:
hsl
(
240
3.7%
15.9%
);
--input
:
hsl
(
240
3.7%
15.9%
);
--ring
:
hsl
(
2
78%
53%
);
/* Radius */
--radius
:
8px
;
/* Typography */
--font-sans
:
"Geist"
,
ui-sans-serif
,
system-ui
,
-apple-system
,
BlinkMacSystemFont
,
"Segoe UI"
,
Roboto
,
sans-serif
;
--font-mono
:
"Geist Mono"
,
ui-monospace
,
monospace
;
/* Shadows */
--shadow-sm
:
0
1px
2px
0
rgb
(
0
0
0
/
0.05
);
--shadow-md
:
0
4px
6px
-1px
rgb
(
0
0
0
/
0.1
),
0
2px
4px
-2px
rgb
(
0
0
0
/
0.1
);
/* Motion */
--duration-fast
:
150ms
;
--duration-normal
:
200ms
;
--ease-out
:
cubic-bezier
(
0.16
,
1
,
0.3
,
1
);
}
/* ================================================================
Base Reset
================================================================ */
*
{
*
,
*
::before
,
*
::after
{
box-sizing
:
border-box
;
margin
:
0
;
padding
:
0
;
...
...
@@ -72,7 +67,8 @@ body {
font-family
:
var
(
--font-sans
);
background
:
var
(
--background
);
color
:
var
(
--foreground
);
font-size
:
13px
;
font-size
:
14px
;
font-weight
:
400
;
line-height
:
1.5
;
-webkit-font-smoothing
:
antialiased
;
-moz-osx-font-smoothing
:
grayscale
;
...
...
@@ -85,77 +81,23 @@ body {
Animations
================================================================ */
@keyframes
fadeIn
{
from
{
opacity
:
0
;
transform
:
translateY
(
6px
);
}
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
from
{
opacity
:
0
;
transform
:
translateY
(
4px
);
}
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
}
@keyframes
slideUp
{
from
{
opacity
:
0
;
transform
:
translateY
(
12px
);
}
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
}
@keyframes
pulse
{
0
%,
100
%
{
opacity
:
1
;
}
50
%
{
opacity
:
0.5
;
}
from
{
opacity
:
0
;
transform
:
translateY
(
8px
);
}
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
}
@keyframes
spin
{
from
{
transform
:
rotate
(
0deg
);
}
to
{
transform
:
rotate
(
360deg
);
}
}
@keyframes
glow
{
0
%,
100
%
{
box-shadow
:
0
0
4px
oklch
(
0.6
0.15
145
/
0.3
);
}
50
%
{
box-shadow
:
0
0
10px
oklch
(
0.6
0.15
145
/
0.5
);
}
from
{
transform
:
rotate
(
0deg
);
}
to
{
transform
:
rotate
(
360deg
);
}
}
@keyframes
successPop
{
0
%
{
transform
:
scale
(
0.9
);
opacity
:
0
;
}
50
%
{
transform
:
scale
(
1.02
);
}
100
%
{
transform
:
scale
(
1
);
opacity
:
1
;
}
@keyframes
pulse-dot
{
0
%,
100
%
{
opacity
:
1
;
}
50
%
{
opacity
:
0.5
;
}
}
/* ================================================================
...
...
@@ -165,7 +107,7 @@ body {
display
:
flex
;
flex-direction
:
column
;
min-height
:
100%
;
animation
:
fadeIn
0.2s
ease-out
;
animation
:
fadeIn
var
(
--duration-normal
)
var
(
--ease-out
);
}
/* ================================================================
...
...
@@ -175,62 +117,84 @@ body {
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
padding
:
14px
18px
;
background
:
var
(
--card
);
padding
:
12px
16px
;
border-bottom
:
1px
solid
var
(
--border
);
back
drop-filter
:
blur
(
10px
);
back
ground
:
var
(
--card
);
}
.header-brand
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
font-weight
:
700
;
font-size
:
1
5px
;
font-weight
:
600
;
font-size
:
1
4px
;
color
:
var
(
--foreground
);
letter-spacing
:
-0.01em
;
letter-spacing
:
-0.02em
;
}
.header-brand-logo
{
width
:
22px
;
height
:
22px
;
border-radius
:
4px
;
object-fit
:
contain
;
}
.header-brand-name
{
font-weight
:
700
;
color
:
var
(
--primary
);
letter-spacing
:
0.02em
;
}
.header-brand-suffix
{
font-weight
:
400
;
color
:
var
(
--muted-foreground
);
font-size
:
13px
;
}
.header-brand-icon
{
font-size
:
20px
;
filter
:
drop-shadow
(
0
1px
2px
oklch
(
0
0
0
/
0.1
));
.header-actions
{
display
:
flex
;
align-items
:
center
;
gap
:
4px
;
}
.header-status
{
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
font-size
:
1
1px
;
font-size
:
1
2px
;
font-weight
:
500
;
color
:
var
(
--muted-foreground
);
}
/* Status Dots */
.status-dot
{
width
:
8px
;
height
:
8px
;
width
:
6px
;
height
:
6px
;
border-radius
:
50%
;
flex-shrink
:
0
;
}
.status-dot.
status-connected
{
.status-dot.
connected
{
background
:
var
(
--success
);
animation
:
glow
2s
ease-in-out
infinite
;
box-shadow
:
0
0
6px
hsl
(
142
71%
45%
/
0.4
);
animation
:
pulse-dot
2s
ease-in-out
infinite
;
}
.status-dot.
status-offline
{
background
:
var
(
--
destructive
);
.status-dot.
disconnected
{
background
:
var
(
--
muted-foreground
);
}
.status-text
{
font-size
:
11px
;
color
:
var
(
--muted-foreground
);
}
/* ================================================================
Content
================================================================ */
.content
{
padding
:
1
6px
18px
;
animation
:
fadeIn
0.
3s
ease-out
;
padding
:
1
4px
16px
;
animation
:
fadeIn
0.
2s
var
(
--ease-out
);
}
/* ================================================================
...
...
@@ -239,36 +203,35 @@ body {
.note-form
{
display
:
flex
;
flex-direction
:
column
;
animation
:
slideUp
0.2
5s
ease-out
;
animation
:
slideUp
0.2
s
var
(
--ease-out
);
}
/*
---- Textarea ---- */
/*
Textarea */
.note-textarea
{
display
:
block
;
width
:
100%
;
min-height
:
100px
;
min-height
:
96px
;
max-height
:
200px
;
resize
:
vertical
;
border
:
1
.5px
solid
var
(
--border
);
border
:
1
px
solid
var
(
--border
);
border-radius
:
var
(
--radius
);
background
:
var
(
--background
);
color
:
var
(
--foreground
);
padding
:
1
2px
14px
;
padding
:
1
0px
12px
;
font-size
:
14px
;
font-family
:
inherit
;
font-family
:
var
(
--font-sans
);
line-height
:
1.6
;
transition
:
border-color
0.2s
ease
,
box-shadow
0.2s
ease
;
transition
:
border-color
var
(
--duration-fast
),
box-shadow
var
(
--duration-fast
);
}
.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
oklch
(
0.45
0.08
45
/
0.1
);
box-shadow
:
0
0
0
2px
hsl
(
2
78%
53%
/
0.15
);
}
/* ================================================================
...
...
@@ -277,93 +240,113 @@ body {
.source-info
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
font-size
:
1
1px
;
gap
:
6px
;
font-size
:
1
2px
;
color
:
var
(
--muted-foreground
);
padding
:
8px
12px
;
background
:
var
(
--
muted
);
padding
:
6px
10px
;
background
:
var
(
--
secondary
);
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
margin-top
:
10px
;
margin-top
:
8px
;
border
:
1px
solid
var
(
--border
);
overflow
:
hidden
;
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
}
.source-icon
{
flex-shrink
:
0
;
width
:
14px
;
height
:
14px
;
color
:
var
(
--muted-foreground
);
}
/* ================================================================
Tags Section
Labels
================================================================ */
.label
{
display
:
block
;
font-size
:
12px
;
font-weight
:
500
;
margin-bottom
:
6px
;
color
:
var
(
--muted-foreground
);
letter-spacing
:
0.01em
;
}
.label-hint
{
font-size
:
11px
;
font-weight
:
600
;
margin-bottom
:
8px
;
color
:
var
(
--muted-foreground
);
text-transform
:
uppercase
;
letter-spacing
:
0.05em
;
margin-top
:
4px
;
opacity
:
0.8
;
}
/* ================================================================
Tags
================================================================ */
.tag-chips
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
6px
;
margin-top
:
6px
;
gap
:
5px
;
margin-top
:
4px
;
}
.tag-chip
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
4px
;
padding
:
5px
12px
;
border-radius
:
100px
;
gap
:
2px
;
padding
:
3px
10px
;
border-radius
:
9999px
;
font-size
:
12px
;
font-weight
:
500
;
cursor
:
pointer
;
transition
:
all
0.2s
cubic-bezier
(
0.4
,
0
,
0.2
,
1
);
border
:
1
.5px
solid
var
(
--border
);
background
:
var
(
--card
);
transition
:
all
var
(
--duration-fast
)
var
(
--ease-out
);
border
:
1
px
solid
var
(
--border
);
background
:
transparent
;
color
:
var
(
--muted-foreground
);
font-family
:
inherit
;
font-family
:
var
(
--font-sans
);
user-select
:
none
;
}
.tag-chip
:hover
{
border-color
:
var
(
--primary
);
color
:
var
(
--primary
);
background
:
oklch
(
0.45
0.08
45
/
0.06
);
transform
:
translateY
(
-1px
);
box-shadow
:
var
(
--shadow-sm
);
border-color
:
hsl
(
2
78%
53%
/
0.5
);
color
:
var
(
--foreground
);
background
:
hsl
(
2
78%
53%
/
0.06
);
}
.tag-chip.selected
{
background
:
var
(
--primary
);
color
:
var
(
--primary-foreground
);
border-color
:
var
(
--primary
);
box-shadow
:
0
2px
6px
oklch
(
0.45
0.08
45
/
0.2
);
}
.tag-chip.selected
:hover
{
filter
:
brightness
(
1.1
);
background
:
var
(
--primary
);
color
:
var
(
--primary-foreground
);
transform
:
translateY
(
-1px
);
}
.tag-chip-icon
{
font-size
:
11px
;
opacity
:
0.6
;
}
.tag-input-inline
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
4px
;
padding
:
5px
12px
;
border-radius
:
100px
;
gap
:
2px
;
padding
:
3px
10px
;
border-radius
:
9999px
;
font-size
:
12px
;
border
:
1
.5px
dashed
var
(
--border
);
border
:
1
px
dashed
var
(
--border
);
background
:
transparent
;
color
:
var
(
--muted-foreground
);
cursor
:
pointer
;
transition
:
all
0.2s
ease
;
font-family
:
inherit
;
transition
:
all
var
(
--duration-fast
)
var
(
--ease-out
);
font-family
:
var
(
--font-sans
);
}
.tag-input-inline
:hover
{
border-color
:
var
(
--primary
);
color
:
var
(
--
primary
);
color
:
var
(
--
foreground
);
}
.tag-input-field
{
...
...
@@ -371,40 +354,66 @@ body {
outline
:
none
;
background
:
transparent
;
font-size
:
12px
;
font-family
:
inherit
;
font-family
:
var
(
--font-sans
);
color
:
var
(
--foreground
);
width
:
80px
;
width
:
70px
;
}
/* ================================================================
Workspace / Select
Form Group / Select
================================================================ */
.form-group
{
margin-bottom
:
1
4px
;
margin-bottom
:
1
2px
;
}
.select
{
display
:
block
;
width
:
100%
;
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
border
:
1
.5px
solid
var
(
--border
);
border-radius
:
var
(
--radius
);
border
:
1
px
solid
var
(
--border
);
background
:
var
(
--background
);
color
:
var
(
--foreground
);
padding
:
10px
36px
10px
14px
;
padding
:
8px
32px
8px
12px
;
font-size
:
13px
;
font-family
:
inherit
;
font-family
:
var
(
--font-sans
);
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='%23
888' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E")
;
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='%23
a1a1aa' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E")
;
background-repeat
:
no-repeat
;
background-position
:
right
1
2px
center
;
transition
:
border-color
0.2s
ease
,
box-shadow
0.2s
ease
;
background-position
:
right
1
0px
center
;
transition
:
border-color
var
(
--duration-fast
),
box-shadow
var
(
--duration-fast
);
}
.select
:focus
{
outline
:
none
;
border-color
:
var
(
--ring
);
box-shadow
:
0
0
0
3px
oklch
(
0.45
0.08
45
/
0.1
);
box-shadow
:
0
0
0
2px
hsl
(
2
78%
53%
/
0.15
);
}
/* ================================================================
Input
================================================================ */
.input
{
display
:
block
;
width
:
100%
;
border-radius
:
var
(
--radius
);
border
:
1px
solid
var
(
--border
);
background
:
var
(
--background
);
color
:
var
(
--foreground
);
padding
:
8px
12px
;
font-size
:
13px
;
font-family
:
var
(
--font-sans
);
transition
:
border-color
var
(
--duration-fast
),
box-shadow
var
(
--duration-fast
);
}
.input
:focus
{
outline
:
none
;
border-color
:
var
(
--ring
);
box-shadow
:
0
0
0
2px
hsl
(
2
78%
53%
/
0.15
);
}
.input
::placeholder
{
color
:
var
(
--muted-foreground
);
}
/* ================================================================
...
...
@@ -413,22 +422,22 @@ body {
.visibility-selector
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
5px
;
padding
:
6px
14px
;
border-radius
:
100px
;
gap
:
4px
;
padding
:
5px
12px
;
border-radius
:
var
(
--radius
);
font-size
:
12px
;
font-weight
:
500
;
border
:
1
.5px
solid
var
(
--border
);
background
:
var
(
--card
);
border
:
1
px
solid
var
(
--border
);
background
:
transparent
;
color
:
var
(
--muted-foreground
);
cursor
:
pointer
;
transition
:
all
0.2s
ease
;
font-family
:
inherit
;
transition
:
all
var
(
--duration-fast
)
var
(
--ease-out
);
font-family
:
var
(
--font-sans
);
}
.visibility-selector
:hover
{
border-color
:
var
(
--primary
);
color
:
var
(
--
primary
);
border-color
:
hsl
(
2
78%
53%
/
0.5
);
color
:
var
(
--
foreground
);
}
/* ================================================================
...
...
@@ -440,65 +449,91 @@ body {
justify-content
:
center
;
gap
:
6px
;
white-space
:
nowrap
;
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
border-radius
:
var
(
--radius
);
font-size
:
13px
;
font-weight
:
600
;
font-family
:
inherit
;
font-weight
:
500
;
font-family
:
var
(
--font-sans
);
cursor
:
pointer
;
border
:
none
;
padding
:
9px
18px
;
transition
:
all
0.2s
cubic-bezier
(
0.4
,
0
,
0.2
,
1
);
padding
:
8px
16px
;
transition
:
all
var
(
--duration-fast
)
var
(
--ease-out
);
}
.btn
:focus-visible
{
outline
:
2px
solid
var
(
--ring
);
outline-offset
:
2px
;
}
.btn
:active
{
transform
:
scale
(
0.9
6
);
transform
:
scale
(
0.9
7
);
}
.btn-primary
{
background
:
linear-gradient
(
135deg
,
var
(
--primary
),
oklch
(
0.5
0.1
50
));
background
:
var
(
--primary
);
color
:
var
(
--primary-foreground
);
box-shadow
:
0
2px
8px
oklch
(
0.45
0.08
45
/
0.25
);
}
.btn-primary
:hover
{
filter
:
brightness
(
1.08
);
box-shadow
:
0
4px
14px
oklch
(
0.45
0.08
45
/
0.35
);
transform
:
translateY
(
-1px
);
background
:
hsl
(
2
78%
48%
);
}
.btn-secondary
{
background
:
var
(
--secondary
);
color
:
var
(
--secondary-foreground
);
border
:
1
.5px
solid
var
(
--border
);
border
:
1
px
solid
var
(
--border
);
}
.btn-secondary
:hover
{
background
:
var
(
--muted
);
transform
:
translateY
(
-1px
);
background
:
hsl
(
240
3.7%
20%
);
}
.btn-ghost
{
background
:
transparent
;
color
:
var
(
--muted-foreground
);
padding
:
6px
1
2px
;
padding
:
6px
1
0px
;
}
.btn-ghost
:hover
{
background
:
var
(
--
muted
);
background
:
var
(
--
accent
);
color
:
var
(
--foreground
);
}
.btn-sm
{
padding
:
6px
1
4px
;
padding
:
6px
1
2px
;
font-size
:
12px
;
border-radius
:
calc
(
var
(
--radius
)
-
4px
);
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
}
.btn-full
{
width
:
100%
;
}
.btn
:disabled
{
opacity
:
0.5
;
cursor
:
not-allowed
;
transform
:
none
!important
;
filter
:
none
!important
;
pointer-events
:
none
;
}
/* Icon Button */
.btn-icon
{
padding
:
6px
;
width
:
28px
;
height
:
28px
;
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
background
:
transparent
;
color
:
var
(
--muted-foreground
);
border
:
none
;
cursor
:
pointer
;
display
:
inline-flex
;
align-items
:
center
;
justify-content
:
center
;
transition
:
all
var
(
--duration-fast
)
var
(
--ease-out
);
font-family
:
var
(
--font-sans
);
}
.btn-icon
:hover
{
background
:
var
(
--accent
);
color
:
var
(
--foreground
);
}
/* ================================================================
...
...
@@ -508,7 +543,7 @@ body {
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
padding
:
1
2px
18px
14px
;
padding
:
1
0px
16px
12px
;
border-top
:
1px
solid
var
(
--border
);
background
:
var
(
--card
);
}
...
...
@@ -522,7 +557,7 @@ body {
.action-bar-right
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
gap
:
6px
;
}
/* ================================================================
...
...
@@ -532,38 +567,120 @@ body {
display
:
inline-flex
;
align-items
:
center
;
gap
:
4px
;
padding
:
4px
10px
;
border-radius
:
100px
;
font-size
:
1
2px
;
font-weight
:
600
;
padding
:
3px
8px
;
border-radius
:
9999px
;
font-size
:
1
1px
;
font-weight
:
500
;
}
.badge-success
{
background
:
oklch
(
0.6
0.15
145
/
0.12
);
background
:
hsl
(
142
71%
45%
/
0.12
);
color
:
var
(
--success
);
border
:
1px
solid
oklch
(
0.6
0.15
145
/
0.2
);
animation
:
successPop
0.3s
ease-out
;
border
:
1px
solid
hsl
(
142
71%
45%
/
0.2
);
}
.badge-error
{
background
:
hsl
(
0
62.8%
30.6%
/
0.12
);
color
:
hsl
(
0
72%
51%
);
border
:
1px
solid
hsl
(
0
62.8%
30.6%
/
0.2
);
}
.error
{
padding
:
10px
14px
;
margin-top
:
10px
;
background
:
oklch
(
0.55
0.15
25
/
0.08
);
color
:
var
(
--destructive
);
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
padding
:
8px
12px
;
margin-top
:
8px
;
background
:
hsl
(
0
62.8%
30.6%
/
0.1
);
color
:
hsl
(
0
72%
51%
);
border-radius
:
var
(
--radius
);
font-size
:
12px
;
border
:
1px
solid
oklch
(
0.55
0.15
25
/
0.15
);
animation
:
fadeIn
0.2s
ease-out
;
border
:
1px
solid
hsl
(
0
62.8%
30.6%
/
0.2
);
animation
:
fadeIn
var
(
--duration-fast
)
var
(
--ease-out
);
}
.success
{
padding
:
10px
14px
;
margin-bottom
:
12px
;
background
:
oklch
(
0.6
0.15
145
/
0.08
);
padding
:
8px
12px
;
margin-bottom
:
8px
;
background
:
hsl
(
142
71%
45%
/
0.1
);
color
:
var
(
--success
);
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
border-radius
:
var
(
--radius
);
font-size
:
12px
;
border
:
1px
solid
hsl
(
142
71%
45%
/
0.2
);
}
/* Inline Banner */
.inline-banner
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
padding
:
10px
12px
;
background
:
var
(
--secondary
);
border
:
1px
solid
var
(
--border
);
border-radius
:
var
(
--radius
);
margin-bottom
:
12px
;
font-size
:
12px
;
border
:
1px
solid
oklch
(
0.6
0.15
145
/
0.15
);
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
;
}
/* ================================================================
...
...
@@ -584,7 +701,7 @@ body {
Scrollbar
================================================================ */
::-webkit-scrollbar
{
width
:
5px
;
width
:
4px
;
}
::-webkit-scrollbar-track
{
...
...
@@ -593,27 +710,9 @@ body {
::-webkit-scrollbar-thumb
{
background
:
var
(
--border
);
border-radius
:
100px
;
border-radius
:
9999px
;
}
::-webkit-scrollbar-thumb:hover
{
background
:
var
(
--muted-foreground
);
}
\ No newline at end of file
/* ================================================================
Smooth transitions on all themed elements
================================================================ */
*,
*
::before
,
*
::after
{
transition-property
:
background-color
,
color
,
border-color
,
box-shadow
,
opacity
;
transition-duration
:
0.2s
;
transition-timing-function
:
ease
;
}
/* Opt-out for animation elements */
.spinner
,
.spinner
*,
[
class
*=
"animate-"
]
{
transition
:
none
!important
;
}
\ No newline at end of file
extension/src/popup/popup.html
View file @
25b29d27
<!DOCTYPE html>
<html
lang=
"en"
>
<html
lang=
"en"
class=
"dark"
>
<head>
<meta
charset=
"UTF-8"
>
<meta
name=
"viewport"
content=
"width=device-width, initial-scale=1.0"
>
<title>
CuCu 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"
>
<title>
Canifa Note
</title>
<link
rel=
"stylesheet"
href=
"./popup.css"
>
<style>
body
{
margin
:
0
;
padding
:
0
;
width
:
380px
;
min-height
:
420px
;
min-height
:
380px
;
max-height
:
560px
;
overflow-y
:
auto
;
}
...
...
extension/src/popup/popup.tsx
View file @
25b29d27
/**
* C
uCu Note Extension Popup
* C
anifa Note Extension Popup
*
* Simple, reliable approach:
* 1. On open → grab fresh Clerk token from any OpenNotion tab
* 2. If token found → show NoteForm
* 3. If no token → show "Sign in" button
* 4. Popup stays OPEN after save
* Redesigned with shadcn/ui dark mode + Canifa branding.
* Auth: Extracts Memos access-token from open tabs.
* Always shows NoteForm — no login wall.
*/
import
{
useState
,
useEffect
,
useCallback
}
from
'react'
;
...
...
@@ -15,36 +13,7 @@ import './popup.css';
const
WEB_APP_URL
=
'http://172.16.2.210:5230'
;
// ========== Theme Hook ==========
function
useTheme
()
{
const
[
theme
,
setTheme
]
=
useState
<
'light'
|
'dark'
|
'system'
>
(
'dark'
);
useEffect
(()
=>
{
const
saved
=
localStorage
.
getItem
(
'cucu-theme'
)
as
'light'
|
'dark'
|
'system'
|
null
;
if
(
saved
)
setTheme
(
saved
);
},
[]);
useEffect
(()
=>
{
localStorage
.
setItem
(
'cucu-theme'
,
theme
);
const
root
=
document
.
documentElement
;
if
(
theme
===
'system'
)
{
const
isDark
=
window
.
matchMedia
(
'(prefers-color-scheme: dark)'
).
matches
;
root
.
setAttribute
(
'data-theme'
,
isDark
?
'dark'
:
'light'
);
}
else
{
root
.
setAttribute
(
'data-theme'
,
theme
);
}
},
[
theme
]);
const
cycleTheme
=
()
=>
{
setTheme
((
prev
)
=>
(
prev
===
'light'
?
'dark'
:
prev
===
'dark'
?
'system'
:
'light'
));
};
const
icon
=
theme
===
'light'
?
'☀️'
:
theme
===
'dark'
?
'🌙'
:
'🌓'
;
return
{
theme
,
cycleTheme
,
icon
};
}
// ========== Token Grabber ==========
// ========== Token Grabber (Memos) ==========
async
function
grabTokenFromTabs
():
Promise
<
string
|
null
>
{
try
{
...
...
@@ -53,9 +22,9 @@ async function grabTokenFromTabs(): Promise<string | null> {
const
url
=
t
.
url
||
''
;
return
(
url
.
includes
(
'172.16.2.210'
)
||
url
.
includes
(
'localhost:
3001'
)
||
url
.
includes
(
'
opennotion'
)
||
url
.
includes
(
'c
ucunote'
)
url
.
includes
(
'localhost:
5230'
)
||
url
.
includes
(
'
cucunote'
)
||
url
.
includes
(
'c
anifa'
)
);
});
...
...
@@ -65,54 +34,45 @@ async function grabTokenFromTabs(): Promise<string | null> {
const
results
=
await
chrome
.
scripting
.
executeScript
({
target
:
{
tabId
:
tab
.
id
},
world
:
'MAIN'
,
func
:
async
()
=>
{
func
:
()
=>
{
try
{
// 1.
Try Memos cookie
// 1.
Memos access-token cookie
const
match
=
document
.
cookie
.
match
(
/
(?:
^|;
)
memos
\.
access-token=
([^
;
]
+
)
/
);
if
(
match
&&
match
[
1
])
return
match
[
1
];
// 2.
Try localStorage
// 2.
localStorage
for
(
let
i
=
0
;
i
<
localStorage
.
length
;
i
++
)
{
const
key
=
localStorage
.
key
(
i
);
if
(
key
&&
(
key
.
includes
(
'
token'
)
||
key
.
includes
(
'session'
)))
{
if
(
key
&&
(
key
.
includes
(
'
access_token'
)
||
key
.
includes
(
'token'
)))
{
const
val
=
localStorage
.
getItem
(
key
);
if
(
val
&&
typeof
val
===
'string'
&&
val
.
length
>
50
&&
!
val
.
startsWith
(
'{'
))
{
return
val
;
}
if
(
val
&&
val
.
length
>
20
&&
!
val
.
startsWith
(
'{'
))
return
val
;
}
}
// 3. Try Clerk
const
clerk
=
(
window
as
any
).
Clerk
;
if
(
clerk
?.
session
?.
getToken
)
{
return
await
clerk
.
session
.
getToken
();
}
}
catch
{
/* ignore */
}
return
null
;
},
});
const
token
=
results
[
0
]?.
result
;
if
(
token
&&
typeof
token
===
'string'
&&
token
.
length
>
50
)
{
// Save to storage for other parts of extension
if
(
token
&&
typeof
token
===
'string'
&&
token
.
length
>
10
)
{
await
chrome
.
storage
.
local
.
set
({
clerkSessionToken
:
token
,
clerkTokenSyncedAt
:
Date
.
now
(),
memosAccessToken
:
token
,
tokenSyncedAt
:
Date
.
now
(),
});
return
token
;
}
}
catch
{
// T
his tab didn't work, try next
// T
ab didn't work, try next
}
}
}
catch
{
/* Tab query failed */
}
// Fallback:
check storage
// Fallback:
stored token
try
{
const
stored
=
await
chrome
.
storage
.
local
.
get
([
'
clerkSessionToken'
,
'clerkTokenSyncedAt'
]);
if
(
stored
.
clerkSessionToken
)
{
const
age
=
Date
.
now
()
-
(
stored
.
clerkTokenSyncedAt
||
0
);
if
(
age
<
5
*
60
*
1000
)
{
// less than 5 minutes old
return
stored
.
clerkSessionToken
;
const
stored
=
await
chrome
.
storage
.
local
.
get
([
'
memosAccessToken'
,
'tokenSyncedAt'
]);
if
(
stored
.
memosAccessToken
)
{
const
age
=
Date
.
now
()
-
(
stored
.
tokenSyncedAt
||
0
);
if
(
age
<
24
*
60
*
60
*
1000
)
{
// Memos tokens last much longer than Clerk
return
stored
.
memosAccessToken
;
}
}
}
catch
{
/* storage failed */
}
...
...
@@ -123,14 +83,12 @@ async function grabTokenFromTabs(): Promise<string | null> {
// ========== Main App ==========
function
App
()
{
const
{
cycleTheme
,
icon
:
themeIcon
}
=
useTheme
();
const
[
status
,
setStatus
]
=
useState
<
'loading'
|
'connected'
|
'disconnected'
>
(
'loading'
);
const
[
isConnected
,
setIsConnected
]
=
useState
(
false
);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
currentUrl
,
setCurrentUrl
]
=
useState
(
''
);
const
[
currentTitle
,
setCurrentTitle
]
=
useState
(
''
);
const
[
pendingText
,
setPendingText
]
=
useState
(
''
);
// Initialize: grab token + tab info
useEffect
(()
=>
{
(
async
()
=>
{
// Get current tab info
...
...
@@ -142,7 +100,7 @@ function App() {
}
}
catch
{
/* ignore */
}
// Get pending note
from context menu / content script
// Get pending note
try
{
const
result
=
await
chrome
.
storage
.
local
.
get
([
'pendingNote'
]);
if
(
result
.
pendingNote
)
{
...
...
@@ -153,22 +111,19 @@ function App() {
// Grab token
const
t
=
await
grabTokenFromTabs
();
setStatus
(
t
?
'connected'
:
'disconnected'
);
setIsConnected
(
!!
t
);
setIsLoading
(
false
);
})();
},
[]);
// Fresh token getter for NoteForm — re-grabs from tab each time
const
getFreshToken
=
useCallback
(
async
():
Promise
<
string
|
null
>
=>
{
const
fresh
=
await
grabTokenFromTabs
();
if
(
fresh
)
setStatus
(
'connected'
);
setIsConnected
(
!!
fresh
);
return
fresh
;
},
[]);
// Retry connection
const
handleRetry
=
async
()
=>
{
setStatus
(
'loading'
);
const
t
=
await
grabTokenFromTabs
();
setStatus
(
t
?
'connected'
:
'disconnected'
);
const
handleOpenApp
=
()
=>
{
chrome
.
tabs
.
create
({
url
:
`
${
WEB_APP_URL
}
`
});
};
return
(
...
...
@@ -176,77 +131,46 @@ function App() {
{
/* Header */
}
<
header
className=
"header"
>
<
div
className=
"header-brand"
>
<
span
className=
"header-brand-icon"
>
🐣
</
span
>
<
span
>
CuCu Note
</
span
>
<
button
className=
"btn btn-ghost btn-sm"
onClick=
{
cycleTheme
}
title=
"Toggle theme"
>
{
themeIcon
}
</
button
>
<
img
src=
"/image.png"
alt=
"Canifa"
className=
"header-brand-logo"
/>
<
span
className=
"header-brand-name"
>
CANIFA
</
span
>
<
span
className=
"header-brand-suffix"
>
Note
</
span
>
</
div
>
<
div
className=
"header-actions"
>
<
div
className=
"header-status"
>
{
status
===
'loading'
&&
(
<>
{
isLoading
?
(
<
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
>
<
span
className=
{
`status-dot ${isConnected ? 'connected' : 'disconnected'}`
}
/>
<
span
className=
"status-text"
>
{
isConnected
?
'Synced'
:
'Offline'
}
</
span
>
</>
)
}
</
div
>
<
button
className=
"btn-icon"
onClick=
{
handleOpenApp
}
title=
"Open Canifa Note"
>
↗
</
button
>
</
div
>
</
header
>
{
/* Loading */
}
{
status
===
'loading'
&&
(
<
div
className=
"content"
style=
{
{
textAlign
:
'center'
,
padding
:
'40px'
}
}
>
<
span
className=
"spinner"
/>
Connecting...
{
/* Connection Banner (only when disconnected) */
}
{
!
isLoading
&&
!
isConnected
&&
(
<
div
className=
"content"
>
<
div
className=
"inline-banner"
>
<
span
className=
"inline-banner-icon"
>
⚡
</
span
>
<
span
>
<
a
onClick=
{
handleOpenApp
}
>
Mở Canifa Note
</
a
>
trên trình duyệt và đăng nhập để đồng bộ.
</
span
>
</
div
>
</
div
>
)
}
{
/* Connected → NoteForm */
}
{
status
===
'connected'
&&
(
{
/* Always show NoteForm */
}
<
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
>
);
}
...
...
extension/src/shared/api-client.ts
View file @
25b29d27
/**
* API Client
- Gọi backend CuCu Note
*
Configurable server URL + tag/workspace fetching
* API Client
— Canifa Note (Memos backend)
*
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'
;
// ========== Config ==========
...
...
@@ -20,7 +20,6 @@ export async function getApiBaseUrl(): Promise<string> {
}
export
async
function
setApiBaseUrl
(
url
:
string
):
Promise
<
void
>
{
// Basic URL validation
const
cleaned
=
url
.
trim
().
replace
(
/
\/
+$/
,
''
);
if
(
cleaned
&&
!
cleaned
.
startsWith
(
'http://'
)
&&
!
cleaned
.
startsWith
(
'https://'
))
{
throw
new
Error
(
'URL must start with http:// or https://'
);
...
...
@@ -31,7 +30,9 @@ export async function setApiBaseUrl(url: string): Promise<void> {
// ========== Auth ==========
async
function
getAuthToken
():
Promise
<
string
|
null
>
{
const
result
=
await
chrome
.
storage
.
local
.
get
([
'clerkSessionToken'
,
'authToken'
]);
const
result
=
await
chrome
.
storage
.
local
.
get
([
'memosAccessToken'
,
'clerkSessionToken'
,
'authToken'
]);
// Priority: Memos token > Clerk token > manual token
if
(
result
.
memosAccessToken
)
return
result
.
memosAccessToken
;
if
(
result
.
clerkSessionToken
)
return
result
.
clerkSessionToken
;
return
result
.
authToken
||
null
;
}
...
...
@@ -48,79 +49,9 @@ export async function getAuthStatus(): Promise<{ isConnected: boolean; tokenLeng
};
}
/**
* Sync Clerk token from the currently active page (must be the frontend app)
*/
export
async
function
syncClerkTokenFromPage
():
Promise
<
string
|
null
>
{
try
{
const
[
tab
]
=
await
chrome
.
tabs
.
query
({
active
:
true
,
currentWindow
:
true
});
if
(
!
tab
.
id
)
return
null
;
const
results
=
await
chrome
.
scripting
.
executeScript
({
target
:
{
tabId
:
tab
.
id
},
func
:
async
()
=>
{
if
(
typeof
window
!==
'undefined'
&&
(
window
as
any
).
Clerk
?.
session
?.
getToken
)
{
try
{
const
token
=
await
(
window
as
any
).
Clerk
.
session
.
getToken
();
return
token
||
null
;
}
catch
{
return
null
;
}
}
return
null
;
},
});
const
token
=
results
[
0
]?.
result
||
null
;
if
(
token
)
{
await
chrome
.
storage
.
local
.
set
({
clerkSessionToken
:
token
});
}
return
token
;
}
catch
{
return
null
;
}
}
// ========== API Helpers ==========
// Token is stale if synced more than 45s ago (Clerk JWT expires ~60s)
const
TOKEN_MAX_AGE_MS
=
45
_000
;
async
function
tryRefreshToken
():
Promise
<
void
>
{
try
{
const
result
=
await
chrome
.
storage
.
local
.
get
([
'clerkTokenSyncedAt'
]);
const
syncedAt
=
result
.
clerkTokenSyncedAt
||
0
;
const
age
=
Date
.
now
()
-
syncedAt
;
// 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
response
=
await
new
Promise
<
any
>
((
resolve
)
=>
{
chrome
.
runtime
.
sendMessage
({
type
:
'REFRESH_TOKEN'
},
(
res
)
=>
{
resolve
(
res
);
});
});
// Background returned fresh token — save it
if
(
response
?.
success
&&
response
?.
token
)
{
await
chrome
.
storage
.
local
.
set
({
clerkSessionToken
:
response
.
token
,
clerkTokenSyncedAt
:
Date
.
now
(),
});
console
.
log
(
'[CuCu API] ✅ Token refreshed'
,
response
.
token
.
length
,
'chars'
);
}
else
{
console
.
log
(
'[CuCu API] ⚠️ Token refresh failed:'
,
response
?.
reason
||
'unknown'
);
}
}
}
catch
(
err
:
any
)
{
console
.
log
(
'[CuCu API] ❌ Token refresh error:'
,
err
?.
message
);
}
}
async
function
apiHeaders
():
Promise
<
Record
<
string
,
string
>>
{
// Try to refresh token if stale
await
tryRefreshToken
();
const
token
=
await
getAuthToken
();
const
headers
:
Record
<
string
,
string
>
=
{
'Content-Type'
:
'application/json'
};
if
(
token
)
{
...
...
@@ -159,7 +90,7 @@ export async function createMemo(data: CreateMemoRequest): Promise<MemoResponse>
});
if
(
!
response
.
ok
)
{
await
response
.
text
();
// consume body
await
response
.
text
();
throw
new
Error
(
`Failed to create memo (
${
response
.
status
}
)`
);
}
...
...
@@ -168,8 +99,7 @@ export async function createMemo(data: CreateMemoRequest): Promise<MemoResponse>
}
/**
* Create a memo using a direct token (from Clerk getToken())
* Bypasses storage-based token reading — always uses fresh JWT
* Create a memo using a direct token
*/
export
async
function
createMemoWithToken
(
token
:
string
,
data
:
CreateMemoRequest
):
Promise
<
MemoResponse
>
{
const
apiBase
=
await
getApiBase
();
...
...
@@ -207,16 +137,14 @@ export async function fetchTags(): Promise<string[]> {
const
memos
:
any
[]
=
await
response
.
json
();
// Extract unique tags from all memos
const
tagSet
=
new
Set
<
string
>
();
for
(
const
memo
of
memos
)
{
if
(
Array
.
isArray
(
memo
.
tags
))
{
memo
.
tags
.
forEach
((
t
:
string
)
=>
tagSet
.
add
(
t
));
}
// Also parse #tag from content
const
hashTags
=
(
memo
.
content
||
''
).
match
(
/#
([
a-zA-Z0-9_
\u
00C0-
\u
024F
\u
1E00-
\u
1EFF
]
+
)
/g
);
if
(
hashTags
)
{
hashTags
.
forEach
((
t
:
string
)
=>
tagSet
.
add
(
t
.
slice
(
1
)));
// remove #
hashTags
.
forEach
((
t
:
string
)
=>
tagSet
.
add
(
t
.
slice
(
1
)));
}
}
...
...
@@ -226,7 +154,7 @@ export async function fetchTags(): Promise<string[]> {
}
}
// ========== Workspaces
(Shortcuts) ==========
// ========== Workspaces
==========
export
interface
WorkspaceItem
{
id
:
number
;
...
...
@@ -243,7 +171,6 @@ export async function fetchWorkspaces(): Promise<WorkspaceItem[]> {
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
,
...
...
@@ -286,7 +213,6 @@ export async function getRecentTags(): Promise<string[]> {
}
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