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"
:
{
"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"
>
<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"
>
<!DOCTYPE html>
<html
lang=
"en"
class=
"dark"
>
<head>
<meta
charset=
"UTF-8"
>
<meta
name=
"viewport"
content=
"width=device-width, initial-scale=1.0"
>
<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"
>
</head>
<body>
<div
id=
"popup-root"
></div>
</body>
</html>
</style>
<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>
</body>
</html>
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)
*/
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
*/
async
function
grabFreshToken
():
Promise
<
string
|
null
>
{
try
{
const
allTabs
=
await
chrome
.
tabs
.
query
({});
const
candidates
=
allTabs
.
filter
((
t
)
=>
{
const
url
=
t
.
url
||
''
;
return
(
url
.
includes
(
'172.16.2.210'
)
||
url
.
includes
(
'localhost:3001'
)
||
url
.
includes
(
'opennotion'
)
||
url
.
includes
(
'cucunote'
)
);
});
for
(
const
tab
of
candidates
)
{
if
(
!
tab
.
id
)
continue
;
try
{
const
results
=
await
chrome
.
scripting
.
executeScript
({
target
:
{
tabId
:
tab
.
id
},
world
:
'MAIN'
,
func
:
async
()
=>
{
try
{
// 1. Try Memos cookie
const
match
=
document
.
cookie
.
match
(
/
(?:
^|;
)
memos
\.
access-token=
([^
;
]
+
)
/
);
if
(
match
&&
match
[
1
])
return
match
[
1
];
// 2. Try localStorage
for
(
let
i
=
0
;
i
<
localStorage
.
length
;
i
++
)
{
const
key
=
localStorage
.
key
(
i
);
if
(
key
&&
(
key
.
includes
(
'token'
)
||
key
.
includes
(
'session'
)))
{
const
val
=
localStorage
.
getItem
(
key
);
if
(
val
&&
typeof
val
===
'string'
&&
val
.
length
>
50
&&
!
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
)
{
await
chrome
.
storage
.
local
.
set
({
clerkSessionToken
:
token
,
clerkTokenSyncedAt
:
Date
.
now
(),
});
console
.
log
(
'[CuCu BG] ✅ Fresh token from tab'
,
tab
.
id
,
`(
${
token
.
length
}
chars)`
);
return
token
;
}
}
catch
{
// This tab didn't work, try next
}
}
}
catch
{
// Tab scanning failed
}
return
null
;
}
chrome
.
runtime
.
onMessage
.
addListener
((
message
,
_sender
,
sendResponse
)
=>
{
// ============ AUTO-SYNC AUTH TOKEN ============
if
(
message
.
type
===
'SYNC_AUTH'
)
{
const
{
clerkSessionToken
}
=
message
.
data
||
{};
if
(
clerkSessionToken
)
{
chrome
.
storage
.
local
.
set
({
clerkSessionToken
,
clerkTokenSyncedAt
:
Date
.
now
(),
});
console
.
log
(
'[CuCu BG] ✅ Clerk token auto-synced from content script'
);
}
sendResponse
({
success
:
true
});
return
false
;
// synchronous response
}
// ============ REFRESH TOKEN (popup requests fresh 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'
});
}
})
.
catch
((
err
)
=>
{
console
.
log
(
'[CuCu BG] ❌ Token refresh failed:'
,
err
?.
message
);
sendResponse
({
success
:
false
,
reason
:
err
?.
message
});
});
return
true
;
// keep channel open for async
}
// ============ 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
,
});
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
,
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
});
/**
* 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 Memos token from any Canifa Note tab
*/
async
function
grabFreshToken
():
Promise
<
string
|
null
>
{
try
{
const
allTabs
=
await
chrome
.
tabs
.
query
({});
const
candidates
=
allTabs
.
filter
((
t
)
=>
{
const
url
=
t
.
url
||
''
;
return
(
url
.
includes
(
'172.16.2.210'
)
||
url
.
includes
(
'localhost:5230'
)
||
url
.
includes
(
'cucunote'
)
||
url
.
includes
(
'canifa'
)
);
});
for
(
const
tab
of
candidates
)
{
if
(
!
tab
.
id
)
continue
;
try
{
const
results
=
await
chrome
.
scripting
.
executeScript
({
target
:
{
tabId
:
tab
.
id
},
world
:
'MAIN'
,
func
:
()
=>
{
try
{
// 1. Memos access-token cookie
const
match
=
document
.
cookie
.
match
(
/
(?:
^|;
)
memos
\.
access-token=
([^
;
]
+
)
/
);
if
(
match
&&
match
[
1
])
return
match
[
1
];
// 2. localStorage
for
(
let
i
=
0
;
i
<
localStorage
.
length
;
i
++
)
{
const
key
=
localStorage
.
key
(
i
);
if
(
key
&&
(
key
.
includes
(
'access_token'
)
||
key
.
includes
(
'token'
)))
{
const
val
=
localStorage
.
getItem
(
key
);
if
(
val
&&
val
.
length
>
20
&&
!
val
.
startsWith
(
'{'
))
return
val
;
}
}
}
catch
{
/* ignore */
}
return
null
;
},
});
const
token
=
results
[
0
]?.
result
;
if
(
token
)
{
await
chrome
.
storage
.
local
.
set
({
memosAccessToken
:
token
,
tokenSyncedAt
:
Date
.
now
(),
});
console
.
log
(
'[Canifa Note] ✅ Token synced from tab'
,
tab
.
id
);
return
token
;
}
}
catch
{
// Try next tab
}
}
}
catch
{
// Tab scanning failed
}
return
null
;
}
chrome
.
runtime
.
onMessage
.
addListener
((
message
,
_sender
,
sendResponse
)
=>
{
// ============ AUTO-SYNC AUTH TOKEN ============
if
(
message
.
type
===
'SYNC_AUTH'
)
{
const
token
=
message
.
data
?.
memosAccessToken
||
message
.
data
?.
clerkSessionToken
;
if
(
token
)
{
chrome
.
storage
.
local
.
set
({
memosAccessToken
:
token
,
tokenSyncedAt
:
Date
.
now
(),
});
console
.
log
(
'[Canifa Note] ✅ Token auto-synced from content script'
);
}
sendResponse
({
success
:
true
});
return
false
;
}
// ============ REFRESH TOKEN ============
if
(
message
.
type
===
'REFRESH_TOKEN'
)
{
grabFreshToken
()
.
then
((
token
)
=>
{
if
(
token
)
{
sendResponse
({
success
:
true
,
token
});
}
else
{
sendResponse
({
success
:
false
,
reason
:
'no_canifa_note_tab'
});
}
})
.
catch
((
err
)
=>
{
sendResponse
({
success
:
false
,
reason
:
err
?.
message
});
});
return
true
;
}
// ============ SHOW NOTE FORM ============
if
(
message
.
type
===
'SHOW_NOTE_FORM'
)
{
chrome
.
action
.
openPopup
();
chrome
.
storage
.
local
.
set
({
pendingNote
:
message
.
data
});
sendResponse
({
success
:
true
});
}
// ============ SAVE NOTE ============
else
if
(
message
.
type
===
'SAVE_NOTE'
)
{
const
{
text
,
url
,
title
}
=
message
.
data
;
const
domain
=
new
URL
(
url
).
hostname
.
replace
(
'www.'
,
''
);
const
tagList
=
[
domain
,
'web-highlight'
];
const
contentWithSource
=
`
${
text
}
\n\n---\nSource: [
${
title
}
](
${
url
}
)`
;
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
;
}
return
true
;
});
extension/src/components/NoteForm.tsx
View file @
25b29d27
/**
* NoteForm Component
*
* 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.
*/
import
{
useState
}
from
'react'
;
import
{
createMemoWithToken
}
from
'../shared/api-client'
;
import
{
TagSelector
}
from
'./TagSelector'
;
import
{
WorkspaceSelector
}
from
'./WorkspaceSelector'
;
interface
NoteFormProps
{
initialText
?:
string
;
initialUrl
?:
string
;
initialTitle
?:
string
;
getToken
:
()
=>
Promise
<
string
|
null
>
;
onSave
?:
()
=>
void
;
onCancel
?:
()
=>
void
;
}
export
function
NoteForm
({
initialText
=
''
,
initialUrl
=
''
,
initialTitle
=
''
,
getToken
,
onSave
,
onCancel
,
}:
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
);
const
[
error
,
setError
]
=
useState
<
string
|
null
>
(
null
);
const
handleSave
=
async
()
=>
{
if
(
!
text
.
trim
())
{
setError
(
'Note cannot be empty'
);
return
;
}
setLoading
(
true
);
setError
(
null
);
setSaved
(
false
);
try
{
// Get FRESH token from Clerk
const
token
=
await
getToken
();
if
(
!
token
)
{
setError
(
'Not authenticated. Please sign in again.'
);
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'
);
setLoading
(
false
);
}
};
return
(
<
div
className=
"note-form"
>
<
div
className=
"content"
>
{
/* Success Banner */
}
{
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
>
)
}
{
/* Text Area */
}
<
textarea
className=
"note-textarea"
placeholder=
"Any thoughts..."
value=
{
text
}
onChange=
{
(
e
)
=>
setText
(
e
.
target
.
value
)
}
rows=
{
4
}
autoFocus
/>
{
/* Source Info */
}
{
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
>
)
}
{
/* Tag Selector */
}
<
div
style=
{
{
marginTop
:
'12px'
}
}
>
<
TagSelector
selectedTags=
{
tags
}
onChange=
{
setTags
}
/>
</
div
>
{
/* Workspace Selector */
}
<
WorkspaceSelector
value=
{
workspace
}
onChange=
{
setWorkspace
}
/>
{
/* Error */
}
{
error
&&
<
div
className=
"error"
>
{
error
}
</
div
>
}
</
div
>
{
/* Action Bar */
}
<
div
className=
"action-bar"
>
<
div
className=
"action-bar-left"
>
<
button
className=
"visibility-selector"
onClick=
{
()
=>
setVisibility
(
visibility
===
'PRIVATE'
?
'PUBLIC'
:
'PRIVATE'
)
}
title=
{
`Click to toggle: ${visibility}`
}
>
{
visibility
===
'PRIVATE'
?
'🔒'
:
'🌐'
}{
' '
}
{
visibility
===
'PRIVATE'
?
'Private'
:
'Public'
}
</
button
>
</
div
>
<
div
className=
"action-bar-right"
>
<
button
className=
"btn btn-ghost btn-sm"
onClick=
{
onCancel
||
(()
=>
window
.
close
())
}
disabled=
{
loading
}
>
Cancel
</
button
>
<
button
className=
"btn btn-primary btn-sm"
onClick=
{
handleSave
}
disabled=
{
loading
||
!
text
.
trim
()
}
>
{
saved
?
(
'✅ Saved!'
)
:
loading
?
(
<>
<
span
className=
"spinner"
/>
Saving...
</>
)
:
(
'Save'
)
}
</
button
>
</
div
>
</
div
>
</
div
>
);
}
/**
* NoteForm Component — shadcn/ui styled
*
* 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'
;
interface
NoteFormProps
{
initialText
?:
string
;
initialUrl
?:
string
;
initialTitle
?:
string
;
getToken
:
()
=>
Promise
<
string
|
null
>
;
onSave
?:
()
=>
void
;
onCancel
?:
()
=>
void
;
}
export
function
NoteForm
({
initialText
=
''
,
initialUrl
=
''
,
initialTitle
=
''
,
getToken
,
onSave
,
onCancel
,
}:
NoteFormProps
)
{
const
[
text
,
setText
]
=
useState
(
initialText
);
const
[
tags
,
setTags
]
=
useState
<
string
[]
>
([]);
const
[
visibility
,
setVisibility
]
=
useState
<
'PRIVATE'
|
'PUBLIC'
>
(
'PRIVATE'
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
saved
,
setSaved
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
<
string
|
null
>
(
null
);
const
handleSave
=
async
()
=>
{
if
(
!
text
.
trim
())
{
setError
(
'Chưa nhập nội dung ghi chú'
);
return
;
}
setLoading
(
true
);
setError
(
null
);
setSaved
(
false
);
try
{
const
token
=
await
getToken
();
if
(
!
token
)
{
setError
(
'Chưa đồng bộ. Mở Canifa Note trên trình duyệt và đăng nhập.'
);
setLoading
(
false
);
return
;
}
let
content
=
text
;
if
(
initialUrl
&&
initialUrl
!==
'about:blank'
)
{
content
+=
`\n\n---\nSource: [
${
initialTitle
||
initialUrl
}
](
${
initialUrl
}
)`
;
}
await
createMemoWithToken
(
token
,
{
content
,
tags
,
visibility
,
});
setLoading
(
false
);
setSaved
(
true
);
setText
(
''
);
setTags
([]);
setError
(
null
);
setTimeout
(()
=>
setSaved
(
false
),
3000
);
if
(
onSave
)
onSave
();
}
catch
(
err
)
{
setError
(
err
instanceof
Error
?
err
.
message
:
'Lưu thất bại'
);
setLoading
(
false
);
}
};
return
(
<
div
className=
"note-form"
>
<
div
className=
"content"
>
{
/* Success */
}
{
saved
&&
(
<
div
className=
"success"
style=
{
{
textAlign
:
'center'
,
animation
:
'fadeIn 0.2s ease'
}
}
>
✓ Đã lưu vào Canifa Note
</
div
>
)
}
{
/* Textarea */
}
<
textarea
className=
"note-textarea"
placeholder=
"Ghi chú..."
value=
{
text
}
onChange=
{
(
e
)
=>
setText
(
e
.
target
.
value
)
}
rows=
{
4
}
autoFocus
/>
{
/* Source */
}
{
initialUrl
&&
initialUrl
!==
'about:blank'
&&
(
<
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
>
)
}
{
/* Tags */
}
<
div
style=
{
{
marginTop
:
'10px'
}
}
>
<
TagSelector
selectedTags=
{
tags
}
onChange=
{
setTags
}
/>
</
div
>
{
/* Error */
}
{
error
&&
<
div
className=
"error"
>
{
error
}
</
div
>
}
</
div
>
{
/* Action Bar */
}
<
div
className=
"action-bar"
>
<
div
className=
"action-bar-left"
>
<
button
className=
"visibility-selector"
onClick=
{
()
=>
setVisibility
(
visibility
===
'PRIVATE'
?
'PUBLIC'
:
'PRIVATE'
)
}
title=
{
`Toggle: ${visibility}`
}
>
{
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
>
<
div
className=
"action-bar-right"
>
<
button
className=
"btn btn-ghost btn-sm"
onClick=
{
onCancel
||
(()
=>
window
.
close
())
}
disabled=
{
loading
}
>
Cancel
</
button
>
<
button
className=
"btn btn-primary btn-sm"
onClick=
{
handleSave
}
disabled=
{
loading
||
!
text
.
trim
()
}
>
{
saved
?
(
'✓ Saved'
)
:
loading
?
(
<>
<
span
className=
"spinner"
style=
{
{
width
:
12
,
height
:
12
}
}
/>
Saving...
</>
)
:
(
'Save'
)
}
</
button
>
</
div
>
</
div
>
</
div
>
);
}
extension/src/components/Settings.tsx
View file @
25b29d27
/**
* Settings Component
* Server URL config, auth sync, connection test
*/
import
{
useState
,
useEffect
}
from
'react'
;
import
{
getApiBaseUrl
,
setApiBaseUrl
,
getAuthStatus
,
syncClerkTokenFromPage
,
testConnection
,
}
from
'../shared/api-client'
;
export
function
Settings
()
{
const
[
serverUrl
,
setServerUrl
]
=
useState
(
''
);
const
[
authStatus
,
setAuthStatus
]
=
useState
<
{
isConnected
:
boolean
;
tokenLength
:
number
}
>
({
isConnected
:
false
,
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
);
useEffect
(()
=>
{
loadSettings
();
},
[]);
const
loadSettings
=
async
()
=>
{
const
url
=
await
getApiBaseUrl
();
setServerUrl
(
url
);
const
status
=
await
getAuthStatus
();
setAuthStatus
(
status
);
};
const
handleSaveUrl
=
async
()
=>
{
const
cleaned
=
serverUrl
.
trim
().
replace
(
/
\/
+$/
,
''
);
// remove trailing slashes
await
setApiBaseUrl
(
cleaned
);
setServerUrl
(
cleaned
);
setSaved
(
true
);
setTestResult
(
null
);
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
);
const
result
=
await
testConnection
();
setTestResult
(
result
);
setTesting
(
false
);
};
return
(
<
div
className=
"content"
>
{
/* Server URL */
}
<
div
className=
"settings-section"
>
<
div
className=
"settings-title"
>
🌐 Server
</
div
>
<
div
className=
"form-group"
>
<
label
className=
"label"
>
API URL
</
label
>
<
div
className=
"settings-input-group"
>
<
input
type=
"url"
className=
"input"
value=
{
serverUrl
}
onChange=
{
(
e
)
=>
{
setServerUrl
(
e
.
target
.
value
);
setSaved
(
false
);
setTestResult
(
null
);
}
}
placeholder=
"https://your-domain.com"
/>
<
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
>
<
div
style=
{
{
marginTop
:
'8px'
}
}
>
<
button
className=
"btn btn-secondary btn-sm btn-full"
onClick=
{
handleTestConnection
}
disabled=
{
testing
}
>
{
testing
?
(
<>
<
span
className=
"spinner"
/>
Testing...
</>
)
:
(
'🔌 Test Connection'
)
}
</
button
>
</
div
>
{
testResult
&&
(
<
div
className=
{
testResult
.
ok
?
'success'
:
'error'
}
style=
{
{
marginTop
:
'8px'
}
}
>
{
testResult
.
message
}
</
div
>
)
}
</
div
>
{
/* Auth */
}
<
div
className=
"settings-section"
>
<
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
>
<
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
>
);
}
/**
* Settings Component — shadcn/ui styled
*/
import
{
useState
,
useEffect
}
from
'react'
;
import
{
getApiBaseUrl
,
setApiBaseUrl
,
getAuthStatus
,
testConnection
,
}
from
'../shared/api-client'
;
export
function
Settings
()
{
const
[
serverUrl
,
setServerUrl
]
=
useState
(
''
);
const
[
authStatus
,
setAuthStatus
]
=
useState
<
{
isConnected
:
boolean
;
tokenLength
:
number
}
>
({
isConnected
:
false
,
tokenLength
:
0
,
});
const
[
testResult
,
setTestResult
]
=
useState
<
{
ok
:
boolean
;
message
:
string
}
|
null
>
(
null
);
const
[
testing
,
setTesting
]
=
useState
(
false
);
const
[
saved
,
setSaved
]
=
useState
(
false
);
useEffect
(()
=>
{
loadSettings
();
},
[]);
const
loadSettings
=
async
()
=>
{
const
url
=
await
getApiBaseUrl
();
setServerUrl
(
url
);
const
status
=
await
getAuthStatus
();
setAuthStatus
(
status
);
};
const
handleSaveUrl
=
async
()
=>
{
const
cleaned
=
serverUrl
.
trim
().
replace
(
/
\/
+$/
,
''
);
await
setApiBaseUrl
(
cleaned
);
setServerUrl
(
cleaned
);
setSaved
(
true
);
setTestResult
(
null
);
setTimeout
(()
=>
setSaved
(
false
),
2000
);
};
const
handleTestConnection
=
async
()
=>
{
setTesting
(
true
);
setTestResult
(
null
);
const
result
=
await
testConnection
();
setTestResult
(
result
);
setTesting
(
false
);
};
return
(
<
div
className=
"content"
>
{
/* Server */
}
<
div
className=
"settings-section"
>
<
div
className=
"settings-title"
>
Server
</
div
>
<
div
className=
"form-group"
>
<
label
className=
"label"
>
API URL
</
label
>
<
div
className=
"settings-input-group"
>
<
input
type=
"url"
className=
"input"
value=
{
serverUrl
}
onChange=
{
(
e
)
=>
{
setServerUrl
(
e
.
target
.
value
);
setSaved
(
false
);
setTestResult
(
null
);
}
}
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
>
<
div
style=
{
{
marginTop
:
'6px'
}
}
>
<
button
className=
"btn btn-secondary btn-sm btn-full"
onClick=
{
handleTestConnection
}
disabled=
{
testing
}
>
{
testing
?
(
<>
<
span
className=
"spinner"
style=
{
{
width
:
12
,
height
:
12
}
}
/>
Testing...
</>
)
:
(
'Test Connection'
)
}
</
button
>
</
div
>
{
testResult
&&
(
<
div
className=
{
testResult
.
ok
?
'success'
:
'error'
}
style=
{
{
marginTop
:
'6px'
}
}
>
{
testResult
.
message
}
</
div
>
)
}
</
div
>
{
/* Auth Status */
}
<
div
className=
"settings-section"
>
<
div
className=
"settings-title"
>
Authentication
</
div
>
<
div
className=
"settings-item"
>
<
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'}`
}
/>
{
authStatus
.
isConnected
?
'Active'
:
'Inactive'
}
</
span
>
</
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
>
Canifa Note
</
strong
>
v1.0.0
<
br
/>
Ghi chú nhanh từ bất kỳ trang web nào.
<
br
/>
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
*/
import
{
useState
,
useEffect
,
useRef
}
from
'react'
;
import
{
fetchTags
,
getRecentTags
,
saveRecentTags
}
from
'../shared/api-client'
;
interface
TagSelectorProps
{
selectedTags
:
string
[];
onChange
:
(
tags
:
string
[])
=>
void
;
}
export
function
TagSelector
({
selectedTags
,
onChange
}:
TagSelectorProps
)
{
const
[
availableTags
,
setAvailableTags
]
=
useState
<
string
[]
>
([]);
const
[
isAdding
,
setIsAdding
]
=
useState
(
false
);
const
[
newTag
,
setNewTag
]
=
useState
(
''
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
inputRef
=
useRef
<
HTMLInputElement
>
(
null
);
useEffect
(()
=>
{
loadTags
();
},
[]);
useEffect
(()
=>
{
if
(
isAdding
&&
inputRef
.
current
)
{
inputRef
.
current
.
focus
();
}
},
[
isAdding
]);
const
loadTags
=
async
()
=>
{
setLoading
(
true
);
try
{
const
[
serverTags
,
recentTags
]
=
await
Promise
.
all
([
fetchTags
(),
getRecentTags
(),
]);
// Merge: recent first, then server tags
const
merged
=
[...
new
Set
([...
recentTags
,
...
serverTags
])];
setAvailableTags
(
merged
);
}
catch
{
// Fallback to empty
}
setLoading
(
false
);
};
const
toggleTag
=
(
tag
:
string
)
=>
{
if
(
selectedTags
.
includes
(
tag
))
{
onChange
(
selectedTags
.
filter
((
t
)
=>
t
!==
tag
));
}
else
{
onChange
([...
selectedTags
,
tag
]);
// Save to recent
saveRecentTags
([
tag
,
...
selectedTags
]);
}
};
const
addNewTag
=
()
=>
{
const
cleaned
=
newTag
.
trim
().
replace
(
/^#/
,
''
);
if
(
cleaned
&&
!
selectedTags
.
includes
(
cleaned
))
{
onChange
([...
selectedTags
,
cleaned
]);
if
(
!
availableTags
.
includes
(
cleaned
))
{
setAvailableTags
([
cleaned
,
...
availableTags
]);
}
saveRecentTags
([
cleaned
,
...
selectedTags
]);
}
setNewTag
(
''
);
setIsAdding
(
false
);
};
const
handleKeyDown
=
(
e
:
React
.
KeyboardEvent
)
=>
{
if
(
e
.
key
===
'Enter'
)
{
e
.
preventDefault
();
addNewTag
();
}
else
if
(
e
.
key
===
'Escape'
)
{
setNewTag
(
''
);
setIsAdding
(
false
);
}
};
// Show max 12 tags to keep UI compact
const
displayTags
=
availableTags
.
slice
(
0
,
12
);
return
(
<
div
className=
"form-group"
>
<
label
className=
"label"
>
Tags
</
label
>
<
div
className=
"tag-chips"
>
{
loading
?
(
<
span
style=
{
{
fontSize
:
'11px'
,
color
:
'var(--muted-foreground)'
}
}
>
Loading tags...
</
span
>
)
:
(
<>
{
displayTags
.
map
((
tag
)
=>
(
<
button
key=
{
tag
}
type=
"button"
className=
{
`tag-chip ${selectedTags.includes(tag) ? 'selected' : ''}`
}
onClick=
{
()
=>
toggleTag
(
tag
)
}
>
<
span
className=
"tag-chip-icon"
>
#
</
span
>
{
tag
}
</
button
>
))
}
{
isAdding
?
(
<
div
className=
"tag-input-inline"
>
<
span
className=
"tag-chip-icon"
>
#
</
span
>
<
input
ref=
{
inputRef
}
type=
"text"
className=
"tag-input-field"
value=
{
newTag
}
onChange=
{
(
e
)
=>
setNewTag
(
e
.
target
.
value
)
}
onKeyDown=
{
handleKeyDown
}
onBlur=
{
addNewTag
}
placeholder=
"new tag"
/>
</
div
>
)
:
(
<
button
type=
"button"
className=
"tag-input-inline"
onClick=
{
()
=>
setIsAdding
(
true
)
}
>
+ Add
</
button
>
)
}
</>
)
}
</
div
>
{
selectedTags
.
length
>
0
&&
(
<
div
className=
"label-hint"
>
{
selectedTags
.
length
}
tag
{
selectedTags
.
length
>
1
?
's'
:
''
}
selected
</
div
>
)
}
</
div
>
);
}
/**
* Tag Selector — shadcn/ui badge chips
*/
import
{
useState
,
useEffect
,
useRef
}
from
'react'
;
import
{
fetchTags
,
getRecentTags
,
saveRecentTags
}
from
'../shared/api-client'
;
interface
TagSelectorProps
{
selectedTags
:
string
[];
onChange
:
(
tags
:
string
[])
=>
void
;
}
export
function
TagSelector
({
selectedTags
,
onChange
}:
TagSelectorProps
)
{
const
[
availableTags
,
setAvailableTags
]
=
useState
<
string
[]
>
([]);
const
[
isAdding
,
setIsAdding
]
=
useState
(
false
);
const
[
newTag
,
setNewTag
]
=
useState
(
''
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
inputRef
=
useRef
<
HTMLInputElement
>
(
null
);
useEffect
(()
=>
{
loadTags
();
},
[]);
useEffect
(()
=>
{
if
(
isAdding
&&
inputRef
.
current
)
{
inputRef
.
current
.
focus
();
}
},
[
isAdding
]);
const
loadTags
=
async
()
=>
{
setLoading
(
true
);
try
{
const
[
serverTags
,
recentTags
]
=
await
Promise
.
all
([
fetchTags
(),
getRecentTags
(),
]);
const
merged
=
[...
new
Set
([...
recentTags
,
...
serverTags
])];
setAvailableTags
(
merged
);
}
catch
{
// Fallback
}
setLoading
(
false
);
};
const
toggleTag
=
(
tag
:
string
)
=>
{
if
(
selectedTags
.
includes
(
tag
))
{
onChange
(
selectedTags
.
filter
((
t
)
=>
t
!==
tag
));
}
else
{
onChange
([...
selectedTags
,
tag
]);
saveRecentTags
([
tag
,
...
selectedTags
]);
}
};
const
addNewTag
=
()
=>
{
const
cleaned
=
newTag
.
trim
().
replace
(
/^#/
,
''
);
if
(
cleaned
&&
!
selectedTags
.
includes
(
cleaned
))
{
onChange
([...
selectedTags
,
cleaned
]);
if
(
!
availableTags
.
includes
(
cleaned
))
{
setAvailableTags
([
cleaned
,
...
availableTags
]);
}
saveRecentTags
([
cleaned
,
...
selectedTags
]);
}
setNewTag
(
''
);
setIsAdding
(
false
);
};
const
handleKeyDown
=
(
e
:
React
.
KeyboardEvent
)
=>
{
if
(
e
.
key
===
'Enter'
)
{
e
.
preventDefault
();
addNewTag
();
}
else
if
(
e
.
key
===
'Escape'
)
{
setNewTag
(
''
);
setIsAdding
(
false
);
}
};
const
displayTags
=
availableTags
.
slice
(
0
,
10
);
return
(
<
div
className=
"form-group"
>
<
label
className=
"label"
>
Tags
</
label
>
<
div
className=
"tag-chips"
>
{
loading
?
(
<
span
style=
{
{
fontSize
:
'11px'
,
color
:
'var(--muted-foreground)'
}
}
>
Loading...
</
span
>
)
:
(
<>
{
displayTags
.
map
((
tag
)
=>
(
<
button
key=
{
tag
}
type=
"button"
className=
{
`tag-chip ${selectedTags.includes(tag) ? 'selected' : ''}`
}
onClick=
{
()
=>
toggleTag
(
tag
)
}
>
<
span
className=
"tag-chip-icon"
>
#
</
span
>
{
tag
}
</
button
>
))
}
{
isAdding
?
(
<
div
className=
"tag-input-inline"
>
<
span
className=
"tag-chip-icon"
>
#
</
span
>
<
input
ref=
{
inputRef
}
type=
"text"
className=
"tag-input-field"
value=
{
newTag
}
onChange=
{
(
e
)
=>
setNewTag
(
e
.
target
.
value
)
}
onKeyDown=
{
handleKeyDown
}
onBlur=
{
addNewTag
}
placeholder=
"tag"
/>
</
div
>
)
:
(
<
button
type=
"button"
className=
"tag-input-inline"
onClick=
{
()
=>
setIsAdding
(
true
)
}
>
+ Add
</
button
>
)
}
</>
)
}
</
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
*/
let
selectedText
=
''
;
let
selectedUrl
=
''
;
let
selectedTitle
=
''
;
// ========== AUTO CLERK 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
for
(
let
attempt
=
0
;
attempt
<
maxRetries
;
attempt
++
)
{
try
{
// 1. Try 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)
for
(
let
i
=
0
;
i
<
localStorage
.
length
;
i
++
)
{
const
key
=
localStorage
.
key
(
i
);
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
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
}
if
(
attempt
<
maxRetries
-
1
)
{
await
new
Promise
((
resolve
)
=>
setTimeout
(
resolve
,
retryDelay
));
}
}
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)
const
hostname
=
window
.
location
.
hostname
;
const
isOpenNotionDomain
=
hostname
===
'172.16.2.210'
||
hostname
===
'localhost'
||
hostname
.
includes
(
'opennotion'
)
||
hostname
.
includes
(
'cucunote'
)
||
hostname
.
includes
(
'cucu-note'
);
if
(
!
isOpenNotionDomain
)
return
;
const
token
=
await
tryExtractClerkToken
();
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
},
});
}
}
// Chạy auto-sync khi content script load
autoSyncClerkToken
();
// Cũng lắng nghe request từ popup để sync on-demand
chrome
.
runtime
.
onMessage
.
addListener
((
message
,
_sender
,
sendResponse
)
=>
{
if
(
message
.
type
===
'GET_CLERK_TOKEN'
)
{
tryExtractClerkToken
().
then
((
token
)
=>
{
sendResponse
({
token
});
});
return
true
;
// keep channel open for async
}
});
// Listen khi user bôi đen text
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
();
e
.
stopPropagation
();
await
handleAutoSave
();
return
;
}
}
});
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
();
return
;
}
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
}
}
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
:
{
text
:
selectedText
,
url
:
selectedUrl
,
title
:
selectedTitle
,
},
},
(
response
)
=>
{
if
(
chrome
.
runtime
.
lastError
)
{
showToast
(
`❌ Lỗi:
${
chrome
.
runtime
.
lastError
.
message
||
'Extension error'
}
`
,
'error'
);
return
;
}
if
(
response
?.
success
)
{
showToast
(
'✅ Đã lưu vào CuCu Note!'
,
'success'
);
// Clear selection
window
.
getSelection
()?.
removeAllRanges
();
selectedText
=
''
;
removeQuickHint
();
}
else
{
const
reason
=
response
?.
error
||
'Không thể lưu note'
;
showToast
(
`❌
${
reason
}
`
,
'error'
);
}
});
}
catch
(
err
:
any
)
{
showToast
(
`❌
${
err
?.
message
||
'Lỗi khi lưu note'
}
`, 'error');
}
}
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';
// Gradient màu đẹp
hint.style.cssText = `
position
:
fixed
;
bottom
:
20
px
;
right
:
20
px
;
background
:
linear
-
gradient
(
135
deg
,
#
a0845c
0
%
,
#
8
b6914
100
%
);
color
:
white
;
padding
:
12
px
20
px
;
border
-
radius
:
8
px
;
font
-
size
:
14
px
;
font
-
weight
:
500
;
box
-
shadow
:
0
4
px
12
px
rgba
(
0
,
0
,
0
,
0.15
);
z
-
index
:
999999
;
font
-
family
:
-
apple
-
system
,
BlinkMacSystemFont
,
'Segoe UI'
,
Roboto
,
sans
-
serif
;
animation
:
cucuSlideUp
0.3
s
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
);
}
}
`;
document.head.appendChild(style);
}
try {
document.body.appendChild(hint);
} catch (e) {
document.documentElement.appendChild(hint);
}
// Auto ẩn sau 3 giây
setTimeout(() => {
removeQuickHint();
}, 3000);
}
function removeQuickHint() {
const hint = document.getElementById('cucu-quick-hint');
if (hint) {
try {
hint.remove();
} catch (e) {
// Ignore
}
}
}
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
}
}
const toast = document.createElement('div');
toast.id = 'cucu-toast';
toast.textContent = message;
// Màu sắc đẹp theo type (gradient)
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%)',
};
toast.style.cssText = `
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);
}
}
/**
* 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 MEMOS TOKEN SYNC ==========
async
function
tryExtractToken
():
Promise
<
string
|
null
>
{
const
maxRetries
=
3
;
const
retryDelay
=
500
;
for
(
let
attempt
=
0
;
attempt
<
maxRetries
;
attempt
++
)
{
try
{
// 1. Memos cookie
const
match
=
document
.
cookie
.
match
(
/
(?:
^|;
)
memos
\.
access-token=
([^
;
]
+
)
/
);
if
(
match
&&
match
[
1
])
return
match
[
1
];
// 2. localStorage
for
(
let
i
=
0
;
i
<
localStorage
.
length
;
i
++
)
{
const
key
=
localStorage
.
key
(
i
);
if
(
key
&&
(
key
.
includes
(
'access_token'
)
||
key
.
includes
(
'token'
)))
{
const
val
=
localStorage
.
getItem
(
key
);
if
(
val
&&
typeof
val
===
'string'
&&
val
.
length
>
20
&&
!
val
.
startsWith
(
'{'
))
{
return
val
;
}
}
}
}
catch
{
// Not ready yet
}
if
(
attempt
<
maxRetries
-
1
)
{
await
new
Promise
((
resolve
)
=>
setTimeout
(
resolve
,
retryDelay
));
}
}
return
null
;
}
async
function
autoSyncToken
()
{
const
hostname
=
window
.
location
.
hostname
;
const
isCanifaDomain
=
hostname
===
'172.16.2.210'
||
hostname
===
'localhost'
||
hostname
.
includes
(
'canifa'
)
||
hostname
.
includes
(
'cucunote'
)
||
hostname
.
includes
(
'cucu-note'
);
if
(
!
isCanifaDomain
)
return
;
const
token
=
await
tryExtractToken
();
if
(
token
)
{
chrome
.
runtime
.
sendMessage
({
type
:
'SYNC_AUTH'
,
data
:
{
memosAccessToken
:
token
},
});
}
}
autoSyncToken
();
// On-demand token request
chrome
.
runtime
.
onMessage
.
addListener
((
message
,
_sender
,
sendResponse
)
=>
{
if
(
message
.
type
===
'GET_TOKEN'
)
{
tryExtractToken
().
then
((
token
)
=>
{
sendResponse
({
token
});
});
return
true
;
}
});
// ========== TEXT SELECTION → AUTO SAVE ==========
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
))
{
e
.
preventDefault
();
e
.
stopPropagation
();
await
handleAutoSave
();
return
;
}
}
});
document
.
addEventListener
(
'keyup'
,
handleTextSelection
);
function
handleTextSelection
()
{
try
{
setTimeout
(()
=>
{
const
selection
=
window
.
getSelection
();
if
(
!
selection
||
selection
.
rangeCount
===
0
)
{
selectedText
=
''
;
removeQuickHint
();
return
;
}
const
text
=
selection
.
toString
().
trim
();
if
(
text
.
length
===
0
)
{
selectedText
=
''
;
removeQuickHint
();
return
;
}
selectedText
=
text
;
selectedUrl
=
window
.
location
.
href
;
selectedTitle
=
document
.
title
;
showQuickHint
();
},
50
);
}
catch
{
// Silently ignore
}
}
async
function
handleAutoSave
()
{
if
(
!
selectedText
)
return
;
try
{
showToast
(
'Đang lưu...'
,
'loading'
);
chrome
.
runtime
.
sendMessage
({
type
:
'SAVE_NOTE'
,
data
:
{
text
:
selectedText
,
url
:
selectedUrl
,
title
:
selectedTitle
,
},
},
(
response
)
=>
{
if
(
chrome
.
runtime
.
lastError
)
{
showToast
(
`Lỗi:
${
chrome
.
runtime
.
lastError
.
message
||
'Extension error'
}
`
,
'error'
);
return
;
}
if
(
response
?.
success
)
{
showToast
(
'Đã lưu vào Canifa Note'
,
'success'
);
window
.
getSelection
()?.
removeAllRanges
();
selectedText
=
''
;
removeQuickHint
();
}
else
{
const
reason
=
response
?.
error
||
'Không thể lưu'
;
showToast
(
reason
,
'error'
);
}
});
}
catch
(
err
:
any
)
{
showToast
(
err
?.
message
||
'Lỗi khi lưu'
,
'error'
);
}
}
// ========== UI: Quick Hint ==========
function
showQuickHint
()
{
removeQuickHint
();
const
hint
=
document
.
createElement
(
'div'
);
hint
.
id
=
'cucu-quick-hint'
;
hint
.
textContent
=
'Space hoặc Enter để lưu nhanh'
;
hint
.
style
.
cssText
=
`
position: fixed;
bottom: 20px;
right: 20px;
background: hsl(240 10% 3.9%);
color: hsl(0 0% 98%);
padding: 10px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
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.2s ease-out;
pointer-events: none;
`
;
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(12px); }
to { opacity: 1; transform: translateY(0); }
}
`
;
document
.
head
.
appendChild
(
style
);
}
try
{
document
.
body
.
appendChild
(
hint
);
}
catch
{
document
.
documentElement
.
appendChild
(
hint
);
}
setTimeout
(()
=>
removeQuickHint
(),
3000
);
}
function
removeQuickHint
()
{
const
hint
=
document
.
getElementById
(
'cucu-quick-hint'
);
if
(
hint
)
{
try
{
hint
.
remove
();
}
catch
{
/* ignore */
}
}
}
// ========== UI: Toast ==========
function
showToast
(
message
:
string
,
type
:
'success'
|
'error'
|
'loading'
=
'success'
)
{
const
oldToast
=
document
.
getElementById
(
'cucu-toast'
);
if
(
oldToast
)
{
try
{
oldToast
.
remove
();
}
catch
{
/* ignore */
}
}
const
toast
=
document
.
createElement
(
'div'
);
toast
.
id
=
'cucu-toast'
;
const
icons
=
{
success
:
'✓'
,
error
:
'✕'
,
loading
:
'⟳'
};
toast
.
textContent
=
`
${
icons
[
type
]}
${
message
}
`
;
const
colors
=
{
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: hsl(240 10% 3.9%);
color:
${
colors
[
type
]}
;
padding: 12px 18px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
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.25s ease-out;
max-width: 320px;
word-wrap: break-word;
`
;
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
{
/* ignore */
}
}
try
{
document
.
body
.
appendChild
(
toast
);
}
catch
{
try
{
document
.
documentElement
.
appendChild
(
toast
);
}
catch
{
return
;
}
}
if
(
type
!==
'loading'
)
{
setTimeout
(()
=>
{
if
(
toast
.
parentNode
)
{
toast
.
style
.
animation
=
'cucuToastSlide 0.2s ease-out reverse'
;
setTimeout
(()
=>
{
try
{
toast
.
remove
();
}
catch
{
/* ignore */
}
},
200
);
}
},
3000
);
}
}
extension/src/popup/popup.css
View file @
25b29d27
/* ================================================================
CuCu Note Extension — Premium Design System
Modern, rounded, glassmorphic UI with warm brown palette
================================================================ */
/* ---- Light Theme (Paper — warm brown/amber) ---- */
: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
);
}
/* ================================================================
Base Reset
================================================================ */
*
{
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
;
}
/* ================================================================
Animations
================================================================ */
@keyframes
fadeIn
{
from
{
opacity
:
0
;
transform
:
translateY
(
6px
);
}
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
;
}
}
@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
);
}
}
@keyframes
successPop
{
0
%
{
transform
:
scale
(
0.9
);
opacity
:
0
;
}
50
%
{
transform
:
scale
(
1.02
);
}
100
%
{
transform
:
scale
(
1
);
opacity
:
1
;
}
}
/* ================================================================
Popup Container
================================================================ */
.popup-container
{
display
:
flex
;
flex-direction
:
column
;
min-height
:
100%
;
animation
:
fadeIn
0.2s
ease-out
;
}
/* ================================================================
Header
================================================================ */
.header
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
padding
:
14px
18px
;
background
:
var
(
--card
);
border-bottom
:
1px
solid
var
(
--border
);
backdrop-filter
:
blur
(
10px
);
}
.header-brand
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
font-weight
:
700
;
font-size
:
15px
;
color
:
var
(
--foreground
);
letter-spacing
:
-0.01em
;
}
.header-brand-icon
{
font-size
:
20px
;
filter
:
drop-shadow
(
0
1px
2px
oklch
(
0
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
================================================================ */
.content
{
padding
:
16px
18px
;
animation
:
fadeIn
0.3s
ease-out
;
}
/* ================================================================
Note Form
================================================================ */
.note-form
{
display
:
flex
;
flex-direction
:
column
;
animation
:
slideUp
0.25s
ease-out
;
}
/* ---- Textarea ---- */
.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
0.2s
ease
,
box-shadow
0.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
oklch
(
0.45
0.08
45
/
0.1
);
}
/* ================================================================
Source Info
================================================================ */
.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
);
}
/* ================================================================
Tags Section
================================================================ */
.label
{
display
:
block
;
font-size
:
11px
;
font-weight
:
600
;
margin-bottom
:
8px
;
color
:
var
(
--muted-foreground
);
text-transform
:
uppercase
;
letter-spacing
:
0.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
0.2s
cubic-bezier
(
0.4
,
0
,
0.2
,
1
);
border
:
1.5px
solid
var
(
--border
);
background
:
var
(
--card
);
color
:
var
(
--muted-foreground
);
font-family
:
inherit
;
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
);
}
.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-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
0.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
;
}
/* ================================================================
Workspace / Select
================================================================ */
.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
0.2s
ease
,
box-shadow
0.2s
ease
;
}
.select
:focus
{
outline
:
none
;
border-color
:
var
(
--ring
);
box-shadow
:
0
0
0
3px
oklch
(
0.45
0.08
45
/
0.1
);
}
/* ================================================================
Visibility Selector
================================================================ */
.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
0.2s
ease
;
font-family
:
inherit
;
}
.visibility-selector
:hover
{
border-color
:
var
(
--primary
);
color
:
var
(
--primary
);
}
/* ================================================================
Buttons
================================================================ */
.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
0.2s
cubic-bezier
(
0.4
,
0
,
0.2
,
1
);
}
.btn
:active
{
transform
:
scale
(
0.96
);
}
.btn-primary
{
background
:
linear-gradient
(
135deg
,
var
(
--primary
),
oklch
(
0.5
0.1
50
));
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
);
}
.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
:
0.5
;
cursor
:
not-allowed
;
transform
:
none
!important
;
filter
:
none
!important
;
}
/* ================================================================
Action Bar (bottom)
================================================================ */
.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
;
}
/* ================================================================
Badges & Messages
================================================================ */
.badge
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
4px
;
padding
:
4px
10px
;
border-radius
:
100px
;
font-size
:
12px
;
font-weight
:
600
;
}
.badge-success
{
background
:
oklch
(
0.6
0.15
145
/
0.12
);
color
:
var
(
--success
);
border
:
1px
solid
oklch
(
0.6
0.15
145
/
0.2
);
animation
:
successPop
0.3s
ease-out
;
}
.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
);
font-size
:
12px
;
border
:
1px
solid
oklch
(
0.55
0.15
25
/
0.15
);
animation
:
fadeIn
0.2s
ease-out
;
}
.success
{
padding
:
10px
14px
;
margin-bottom
:
12px
;
background
:
oklch
(
0.6
0.15
145
/
0.08
);
color
:
var
(
--success
);
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
font-size
:
12px
;
border
:
1px
solid
oklch
(
0.6
0.15
145
/
0.15
);
}
/* ================================================================
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
:
5px
;
}
::-webkit-scrollbar-track
{
background
:
transparent
;
}
::-webkit-scrollbar-thumb
{
background
:
var
(
--border
);
border-radius
:
100px
;
}
::-webkit-scrollbar-thumb:hover
{
background
:
var
(
--muted-foreground
);
}
/* ================================================================
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
;
/* ================================================================
Canifa Note Extension — shadcn/ui Design System
Dark-first, token-driven, Canifa red accent
================================================================ */
/* ---- Design Tokens ---- */
:root
{
/* 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
;
}
body
{
font-family
:
var
(
--font-sans
);
background
:
var
(
--background
);
color
:
var
(
--foreground
);
font-size
:
14px
;
font-weight
:
400
;
line-height
:
1.5
;
-webkit-font-smoothing
:
antialiased
;
-moz-osx-font-smoothing
:
grayscale
;
width
:
380px
;
min-height
:
200px
;
overflow-x
:
hidden
;
}
/* ================================================================
Animations
================================================================ */
@keyframes
fadeIn
{
from
{
opacity
:
0
;
transform
:
translateY
(
4px
);
}
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
}
@keyframes
slideUp
{
from
{
opacity
:
0
;
transform
:
translateY
(
8px
);
}
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
}
@keyframes
spin
{
from
{
transform
:
rotate
(
0deg
);
}
to
{
transform
:
rotate
(
360deg
);
}
}
@keyframes
pulse-dot
{
0
%,
100
%
{
opacity
:
1
;
}
50
%
{
opacity
:
0.5
;
}
}
/* ================================================================
Popup Container
================================================================ */
.popup-container
{
display
:
flex
;
flex-direction
:
column
;
min-height
:
100%
;
animation
:
fadeIn
var
(
--duration-normal
)
var
(
--ease-out
);
}
/* ================================================================
Header
================================================================ */
.header
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
padding
:
12px
16px
;
border-bottom
:
1px
solid
var
(
--border
);
background
:
var
(
--card
);
}
.header-brand
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
font-weight
:
600
;
font-size
:
14px
;
color
:
var
(
--foreground
);
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-actions
{
display
:
flex
;
align-items
:
center
;
gap
:
4px
;
}
.header-status
{
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
font-size
:
12px
;
font-weight
:
500
;
color
:
var
(
--muted-foreground
);
}
/* Status Dots */
.status-dot
{
width
:
6px
;
height
:
6px
;
border-radius
:
50%
;
flex-shrink
:
0
;
}
.status-dot.connected
{
background
:
var
(
--success
);
box-shadow
:
0
0
6px
hsl
(
142
71%
45%
/
0.4
);
animation
:
pulse-dot
2s
ease-in-out
infinite
;
}
.status-dot.disconnected
{
background
:
var
(
--muted-foreground
);
}
.status-text
{
font-size
:
11px
;
color
:
var
(
--muted-foreground
);
}
/* ================================================================
Content
================================================================ */
.content
{
padding
:
14px
16px
;
animation
:
fadeIn
0.2s
var
(
--ease-out
);
}
/* ================================================================
Note Form
================================================================ */
.note-form
{
display
:
flex
;
flex-direction
:
column
;
animation
:
slideUp
0.2s
var
(
--ease-out
);
}
/* Textarea */
.note-textarea
{
display
:
block
;
width
:
100%
;
min-height
:
96px
;
max-height
:
200px
;
resize
:
vertical
;
border
:
1px
solid
var
(
--border
);
border-radius
:
var
(
--radius
);
background
:
var
(
--background
);
color
:
var
(
--foreground
);
padding
:
10px
12px
;
font-size
:
14px
;
font-family
:
var
(
--font-sans
);
line-height
:
1.6
;
transition
:
border-color
var
(
--duration-fast
),
box-shadow
var
(
--duration-fast
);
}
.note-textarea
::placeholder
{
color
:
var
(
--muted-foreground
);
}
.note-textarea
:focus
{
outline
:
none
;
border-color
:
var
(
--ring
);
box-shadow
:
0
0
0
2px
hsl
(
2
78%
53%
/
0.15
);
}
/* ================================================================
Source Info
================================================================ */
.source-info
{
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
font-size
:
12px
;
color
:
var
(
--muted-foreground
);
padding
:
6px
10px
;
background
:
var
(
--secondary
);
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
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
);
}
/* ================================================================
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
;
color
:
var
(
--muted-foreground
);
margin-top
:
4px
;
opacity
:
0.8
;
}
/* ================================================================
Tags
================================================================ */
.tag-chips
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
5px
;
margin-top
:
4px
;
}
.tag-chip
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
2px
;
padding
:
3px
10px
;
border-radius
:
9999px
;
font-size
:
12px
;
font-weight
:
500
;
cursor
:
pointer
;
transition
:
all
var
(
--duration-fast
)
var
(
--ease-out
);
border
:
1px
solid
var
(
--border
);
background
:
transparent
;
color
:
var
(
--muted-foreground
);
font-family
:
var
(
--font-sans
);
user-select
:
none
;
}
.tag-chip
:hover
{
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
);
}
.tag-chip.selected
:hover
{
filter
:
brightness
(
1.1
);
background
:
var
(
--primary
);
color
:
var
(
--primary-foreground
);
}
.tag-chip-icon
{
font-size
:
11px
;
opacity
:
0.6
;
}
.tag-input-inline
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
2px
;
padding
:
3px
10px
;
border-radius
:
9999px
;
font-size
:
12px
;
border
:
1px
dashed
var
(
--border
);
background
:
transparent
;
color
:
var
(
--muted-foreground
);
cursor
:
pointer
;
transition
:
all
var
(
--duration-fast
)
var
(
--ease-out
);
font-family
:
var
(
--font-sans
);
}
.tag-input-inline
:hover
{
border-color
:
var
(
--primary
);
color
:
var
(
--foreground
);
}
.tag-input-field
{
border
:
none
;
outline
:
none
;
background
:
transparent
;
font-size
:
12px
;
font-family
:
var
(
--font-sans
);
color
:
var
(
--foreground
);
width
:
70px
;
}
/* ================================================================
Form Group / Select
================================================================ */
.form-group
{
margin-bottom
:
12px
;
}
.select
{
display
:
block
;
width
:
100%
;
border-radius
:
var
(
--radius
);
border
:
1px
solid
var
(
--border
);
background
:
var
(
--background
);
color
:
var
(
--foreground
);
padding
:
8px
32px
8px
12px
;
font-size
:
13px
;
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='%23a1a1aa' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E")
;
background-repeat
:
no-repeat
;
background-position
:
right
10px
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
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
);
}
/* ================================================================
Visibility Selector
================================================================ */
.visibility-selector
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
4px
;
padding
:
5px
12px
;
border-radius
:
var
(
--radius
);
font-size
:
12px
;
font-weight
:
500
;
border
:
1px
solid
var
(
--border
);
background
:
transparent
;
color
:
var
(
--muted-foreground
);
cursor
:
pointer
;
transition
:
all
var
(
--duration-fast
)
var
(
--ease-out
);
font-family
:
var
(
--font-sans
);
}
.visibility-selector
:hover
{
border-color
:
hsl
(
2
78%
53%
/
0.5
);
color
:
var
(
--foreground
);
}
/* ================================================================
Buttons
================================================================ */
.btn
{
display
:
inline-flex
;
align-items
:
center
;
justify-content
:
center
;
gap
:
6px
;
white-space
:
nowrap
;
border-radius
:
var
(
--radius
);
font-size
:
13px
;
font-weight
:
500
;
font-family
:
var
(
--font-sans
);
cursor
:
pointer
;
border
:
none
;
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.97
);
}
.btn-primary
{
background
:
var
(
--primary
);
color
:
var
(
--primary-foreground
);
}
.btn-primary
:hover
{
background
:
hsl
(
2
78%
48%
);
}
.btn-secondary
{
background
:
var
(
--secondary
);
color
:
var
(
--secondary-foreground
);
border
:
1px
solid
var
(
--border
);
}
.btn-secondary
:hover
{
background
:
hsl
(
240
3.7%
20%
);
}
.btn-ghost
{
background
:
transparent
;
color
:
var
(
--muted-foreground
);
padding
:
6px
10px
;
}
.btn-ghost
:hover
{
background
:
var
(
--accent
);
color
:
var
(
--foreground
);
}
.btn-sm
{
padding
:
6px
12px
;
font-size
:
12px
;
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
}
.btn-full
{
width
:
100%
;
}
.btn
:disabled
{
opacity
:
0.5
;
cursor
:
not-allowed
;
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
);
}
/* ================================================================
Action Bar (bottom)
================================================================ */
.action-bar
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
padding
:
10px
16px
12px
;
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
:
6px
;
}
/* ================================================================
Badges & Messages
================================================================ */
.badge
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
4px
;
padding
:
3px
8px
;
border-radius
:
9999px
;
font-size
:
11px
;
font-weight
:
500
;
}
.badge-success
{
background
:
hsl
(
142
71%
45%
/
0.12
);
color
:
var
(
--success
);
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
:
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
hsl
(
0
62.8%
30.6%
/
0.2
);
animation
:
fadeIn
var
(
--duration-fast
)
var
(
--ease-out
);
}
.success
{
padding
:
8px
12px
;
margin-bottom
:
8px
;
background
:
hsl
(
142
71%
45%
/
0.1
);
color
:
var
(
--success
);
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
;
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>
<html
lang=
"en"
>
<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"
>
<link
rel=
"stylesheet"
href=
"./popup.css"
>
<style>
body
{
margin
:
0
;
padding
:
0
;
width
:
380px
;
min-height
:
420px
;
max-height
:
560px
;
overflow-y
:
auto
;
}
</style>
</head>
<body>
<div
id=
"popup-root"
></div>
<script
type=
"module"
src=
"./popup.tsx"
></script>
</body>
</html>
<!DOCTYPE html>
<html
lang=
"en"
class=
"dark"
>
<head>
<meta
charset=
"UTF-8"
>
<meta
name=
"viewport"
content=
"width=device-width, initial-scale=1.0"
>
<title>
Canifa Note
</title>
<link
rel=
"stylesheet"
href=
"./popup.css"
>
<style>
body
{
margin
:
0
;
padding
:
0
;
width
:
380px
;
min-height
:
380px
;
max-height
:
560px
;
overflow-y
:
auto
;
}
</style>
</head>
<body>
<div
id=
"popup-root"
></div>
<script
type=
"module"
src=
"./popup.tsx"
></script>
</body>
</html>
extension/src/popup/popup.tsx
View file @
25b29d27
/**
* CuCu 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
*/
import
{
useState
,
useEffect
,
useCallback
}
from
'react'
;
import
{
createRoot
}
from
'react-dom/client'
;
import
{
NoteForm
}
from
'../components/NoteForm'
;
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 ==========
async
function
grabTokenFromTabs
():
Promise
<
string
|
null
>
{
try
{
const
allTabs
=
await
chrome
.
tabs
.
query
({});
const
candidates
=
allTabs
.
filter
((
t
)
=>
{
const
url
=
t
.
url
||
''
;
return
(
url
.
includes
(
'172.16.2.210'
)
||
url
.
includes
(
'localhost:3001'
)
||
url
.
includes
(
'opennotion'
)
||
url
.
includes
(
'cucunote'
)
);
});
for
(
const
tab
of
candidates
)
{
if
(
!
tab
.
id
)
continue
;
try
{
const
results
=
await
chrome
.
scripting
.
executeScript
({
target
:
{
tabId
:
tab
.
id
},
world
:
'MAIN'
,
func
:
async
()
=>
{
try
{
// 1. Try Memos cookie
const
match
=
document
.
cookie
.
match
(
/
(?:
^|;
)
memos
\.
access-token=
([^
;
]
+
)
/
);
if
(
match
&&
match
[
1
])
return
match
[
1
];
// 2. Try localStorage
for
(
let
i
=
0
;
i
<
localStorage
.
length
;
i
++
)
{
const
key
=
localStorage
.
key
(
i
);
if
(
key
&&
(
key
.
includes
(
'token'
)
||
key
.
includes
(
'session'
)))
{
const
val
=
localStorage
.
getItem
(
key
);
if
(
val
&&
typeof
val
===
'string'
&&
val
.
length
>
50
&&
!
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
await
chrome
.
storage
.
local
.
set
({
clerkSessionToken
:
token
,
clerkTokenSyncedAt
:
Date
.
now
(),
});
return
token
;
}
}
catch
{
// This tab didn't work, try next
}
}
}
catch
{
/* Tab query failed */
}
// Fallback: check storage
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
;
}
}
}
catch
{
/* storage failed */
}
return
null
;
}
// ========== Main App ==========
function
App
()
{
const
{
cycleTheme
,
icon
:
themeIcon
}
=
useTheme
();
const
[
status
,
setStatus
]
=
useState
<
'loading'
|
'connected'
|
'disconnected'
>
(
'loading'
);
const
[
currentUrl
,
setCurrentUrl
]
=
useState
(
''
);
const
[
currentTitle
,
setCurrentTitle
]
=
useState
(
''
);
const
[
pendingText
,
setPendingText
]
=
useState
(
''
);
// Initialize: grab token + tab info
useEffect
(()
=>
{
(
async
()
=>
{
// Get current tab info
try
{
const
[
tab
]
=
await
chrome
.
tabs
.
query
({
active
:
true
,
currentWindow
:
true
});
if
(
tab
)
{
setCurrentUrl
(
tab
.
url
||
''
);
setCurrentTitle
(
tab
.
title
||
''
);
}
}
catch
{
/* ignore */
}
// Get pending note from context menu / content script
try
{
const
result
=
await
chrome
.
storage
.
local
.
get
([
'pendingNote'
]);
if
(
result
.
pendingNote
)
{
setPendingText
(
result
.
pendingNote
.
text
||
''
);
chrome
.
storage
.
local
.
remove
([
'pendingNote'
]);
}
}
catch
{
/* ignore */
}
// Grab token
const
t
=
await
grabTokenFromTabs
();
setStatus
(
t
?
'connected'
:
'disconnected'
);
})();
},
[]);
// 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'
);
return
fresh
;
},
[]);
// Retry connection
const
handleRetry
=
async
()
=>
{
setStatus
(
'loading'
);
const
t
=
await
grabTokenFromTabs
();
setStatus
(
t
?
'connected'
:
'disconnected'
);
};
return
(
<
div
className=
"popup-container"
>
{
/* 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
>
</
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
/>);
}
/**
* Canifa Note Extension Popup
*
* 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'
;
import
{
createRoot
}
from
'react-dom/client'
;
import
{
NoteForm
}
from
'../components/NoteForm'
;
import
'./popup.css'
;
const
WEB_APP_URL
=
'http://172.16.2.210:5230'
;
// ========== Token Grabber (Memos) ==========
async
function
grabTokenFromTabs
():
Promise
<
string
|
null
>
{
try
{
const
allTabs
=
await
chrome
.
tabs
.
query
({});
const
candidates
=
allTabs
.
filter
((
t
)
=>
{
const
url
=
t
.
url
||
''
;
return
(
url
.
includes
(
'172.16.2.210'
)
||
url
.
includes
(
'localhost:5230'
)
||
url
.
includes
(
'cucunote'
)
||
url
.
includes
(
'canifa'
)
);
});
for
(
const
tab
of
candidates
)
{
if
(
!
tab
.
id
)
continue
;
try
{
const
results
=
await
chrome
.
scripting
.
executeScript
({
target
:
{
tabId
:
tab
.
id
},
world
:
'MAIN'
,
func
:
()
=>
{
try
{
// 1. Memos access-token cookie
const
match
=
document
.
cookie
.
match
(
/
(?:
^|;
)
memos
\.
access-token=
([^
;
]
+
)
/
);
if
(
match
&&
match
[
1
])
return
match
[
1
];
// 2. localStorage
for
(
let
i
=
0
;
i
<
localStorage
.
length
;
i
++
)
{
const
key
=
localStorage
.
key
(
i
);
if
(
key
&&
(
key
.
includes
(
'access_token'
)
||
key
.
includes
(
'token'
)))
{
const
val
=
localStorage
.
getItem
(
key
);
if
(
val
&&
val
.
length
>
20
&&
!
val
.
startsWith
(
'{'
))
return
val
;
}
}
}
catch
{
/* ignore */
}
return
null
;
},
});
const
token
=
results
[
0
]?.
result
;
if
(
token
&&
typeof
token
===
'string'
&&
token
.
length
>
10
)
{
await
chrome
.
storage
.
local
.
set
({
memosAccessToken
:
token
,
tokenSyncedAt
:
Date
.
now
(),
});
return
token
;
}
}
catch
{
// Tab didn't work, try next
}
}
}
catch
{
/* Tab query failed */
}
// Fallback: stored token
try
{
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 */
}
return
null
;
}
// ========== Main App ==========
function
App
()
{
const
[
isConnected
,
setIsConnected
]
=
useState
(
false
);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
currentUrl
,
setCurrentUrl
]
=
useState
(
''
);
const
[
currentTitle
,
setCurrentTitle
]
=
useState
(
''
);
const
[
pendingText
,
setPendingText
]
=
useState
(
''
);
useEffect
(()
=>
{
(
async
()
=>
{
// Get current tab info
try
{
const
[
tab
]
=
await
chrome
.
tabs
.
query
({
active
:
true
,
currentWindow
:
true
});
if
(
tab
)
{
setCurrentUrl
(
tab
.
url
||
''
);
setCurrentTitle
(
tab
.
title
||
''
);
}
}
catch
{
/* ignore */
}
// Get pending note
try
{
const
result
=
await
chrome
.
storage
.
local
.
get
([
'pendingNote'
]);
if
(
result
.
pendingNote
)
{
setPendingText
(
result
.
pendingNote
.
text
||
''
);
chrome
.
storage
.
local
.
remove
([
'pendingNote'
]);
}
}
catch
{
/* ignore */
}
// Grab token
const
t
=
await
grabTokenFromTabs
();
setIsConnected
(
!!
t
);
setIsLoading
(
false
);
})();
},
[]);
const
getFreshToken
=
useCallback
(
async
():
Promise
<
string
|
null
>
=>
{
const
fresh
=
await
grabTokenFromTabs
();
setIsConnected
(
!!
fresh
);
return
fresh
;
},
[]);
const
handleOpenApp
=
()
=>
{
chrome
.
tabs
.
create
({
url
:
`
${
WEB_APP_URL
}
`
});
};
return
(
<
div
className=
"popup-container"
>
{
/* Header */
}
<
header
className=
"header"
>
<
div
className=
"header-brand"
>
<
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"
>
{
isLoading
?
(
<
span
className=
"spinner"
style=
{
{
width
:
10
,
height
:
10
}
}
/>
)
:
(
<>
<
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
>
{
/* 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
>
)
}
{
/* Always show NoteForm */
}
<
NoteForm
initialText=
{
pendingText
}
initialUrl=
{
currentUrl
}
initialTitle=
{
currentTitle
}
getToken=
{
getFreshToken
}
/>
</
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
* Configurable server URL + tag/workspace fetching
*/
// Default API base URL — empty means user must configure in Settings
const
DEFAULT_API_BASE_URL
=
'http://172.16.2.210:5230'
;
// ========== Config ==========
async
function
getApiBase
():
Promise
<
string
>
{
const
result
=
await
chrome
.
storage
.
local
.
get
([
'apiBaseUrl'
]);
const
baseUrl
=
result
.
apiBaseUrl
||
DEFAULT_API_BASE_URL
;
return
`
${
baseUrl
}
/api/v1`
;
}
export
async
function
getApiBaseUrl
():
Promise
<
string
>
{
const
result
=
await
chrome
.
storage
.
local
.
get
([
'apiBaseUrl'
]);
return
result
.
apiBaseUrl
||
DEFAULT_API_BASE_URL
;
}
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://'
);
}
await
chrome
.
storage
.
local
.
set
({
apiBaseUrl
:
cleaned
});
}
// ========== Auth ==========
async
function
getAuthToken
():
Promise
<
string
|
null
>
{
const
result
=
await
chrome
.
storage
.
local
.
get
([
'clerkSessionToken'
,
'authToken'
]);
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
getAuthStatus
():
Promise
<
{
isConnected
:
boolean
;
tokenLength
:
number
}
>
{
const
token
=
await
getAuthToken
();
return
{
isConnected
:
!!
token
&&
token
.
length
>
0
,
tokenLength
:
token
?.
length
||
0
,
};
}
/**
* 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
)
{
headers
[
'Authorization'
]
=
`Bearer
${
token
}
`
;
}
return
headers
;
}
// ========== Memo CRUD ==========
export
interface
CreateMemoRequest
{
content
:
string
;
tags
?:
string
[];
visibility
?:
string
;
}
export
interface
MemoResponse
{
id
:
string
;
content
:
string
;
tags
:
string
[];
created_at
:
string
;
}
export
async
function
createMemo
(
data
:
CreateMemoRequest
):
Promise
<
MemoResponse
>
{
const
apiBase
=
await
getApiBase
();
const
headers
=
await
apiHeaders
();
const
response
=
await
fetch
(
`
${
apiBase
}
/memos`
,
{
method
:
'POST'
,
headers
,
body
:
JSON
.
stringify
({
content
:
data
.
content
,
tags
:
data
.
tags
||
[],
visibility
:
data
.
visibility
||
'PRIVATE'
,
}),
});
if
(
!
response
.
ok
)
{
await
response
.
text
();
// consume body
throw
new
Error
(
`Failed to create memo (
${
response
.
status
}
)`
);
}
const
result
=
await
response
.
json
();
return
result
;
}
/**
* Create a memo using a direct token (from Clerk getToken())
* Bypasses storage-based token reading — always uses fresh JWT
*/
export
async
function
createMemoWithToken
(
token
:
string
,
data
:
CreateMemoRequest
):
Promise
<
MemoResponse
>
{
const
apiBase
=
await
getApiBase
();
const
response
=
await
fetch
(
`
${
apiBase
}
/memos`
,
{
method
:
'POST'
,
headers
:
{
'Content-Type'
:
'application/json'
,
'Authorization'
:
`Bearer
${
token
}
`
,
},
body
:
JSON
.
stringify
({
content
:
data
.
content
,
tags
:
data
.
tags
||
[],
visibility
:
data
.
visibility
||
'PRIVATE'
,
}),
});
if
(
!
response
.
ok
)
{
const
errorText
=
await
response
.
text
();
throw
new
Error
(
`Failed to save (
${
response
.
status
}
):
${
errorText
.
substring
(
0
,
100
)}
`
);
}
return
await
response
.
json
();
}
// ========== Tags ==========
export
async
function
fetchTags
():
Promise
<
string
[]
>
{
try
{
const
apiBase
=
await
getApiBase
();
const
headers
=
await
apiHeaders
();
const
response
=
await
fetch
(
`
${
apiBase
}
/memos`
,
{
headers
});
if
(
!
response
.
ok
)
return
[];
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 #
}
}
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
});
}
/**
* API Client — Canifa Note (Memos backend)
* Uses Memos access-token for auth
*/
// Default API base URL
const
DEFAULT_API_BASE_URL
=
'http://172.16.2.210:5230'
;
// ========== Config ==========
async
function
getApiBase
():
Promise
<
string
>
{
const
result
=
await
chrome
.
storage
.
local
.
get
([
'apiBaseUrl'
]);
const
baseUrl
=
result
.
apiBaseUrl
||
DEFAULT_API_BASE_URL
;
return
`
${
baseUrl
}
/api/v1`
;
}
export
async
function
getApiBaseUrl
():
Promise
<
string
>
{
const
result
=
await
chrome
.
storage
.
local
.
get
([
'apiBaseUrl'
]);
return
result
.
apiBaseUrl
||
DEFAULT_API_BASE_URL
;
}
export
async
function
setApiBaseUrl
(
url
:
string
):
Promise
<
void
>
{
const
cleaned
=
url
.
trim
().
replace
(
/
\/
+$/
,
''
);
if
(
cleaned
&&
!
cleaned
.
startsWith
(
'http://'
)
&&
!
cleaned
.
startsWith
(
'https://'
))
{
throw
new
Error
(
'URL must start with http:// or https://'
);
}
await
chrome
.
storage
.
local
.
set
({
apiBaseUrl
:
cleaned
});
}
// ========== Auth ==========
async
function
getAuthToken
():
Promise
<
string
|
null
>
{
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
;
}
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
();
return
{
isConnected
:
!!
token
&&
token
.
length
>
0
,
tokenLength
:
token
?.
length
||
0
,
};
}
// ========== API Helpers ==========
async
function
apiHeaders
():
Promise
<
Record
<
string
,
string
>>
{
const
token
=
await
getAuthToken
();
const
headers
:
Record
<
string
,
string
>
=
{
'Content-Type'
:
'application/json'
};
if
(
token
)
{
headers
[
'Authorization'
]
=
`Bearer
${
token
}
`
;
}
return
headers
;
}
// ========== Memo CRUD ==========
export
interface
CreateMemoRequest
{
content
:
string
;
tags
?:
string
[];
visibility
?:
string
;
}
export
interface
MemoResponse
{
id
:
string
;
content
:
string
;
tags
:
string
[];
created_at
:
string
;
}
export
async
function
createMemo
(
data
:
CreateMemoRequest
):
Promise
<
MemoResponse
>
{
const
apiBase
=
await
getApiBase
();
const
headers
=
await
apiHeaders
();
const
response
=
await
fetch
(
`
${
apiBase
}
/memos`
,
{
method
:
'POST'
,
headers
,
body
:
JSON
.
stringify
({
content
:
data
.
content
,
tags
:
data
.
tags
||
[],
visibility
:
data
.
visibility
||
'PRIVATE'
,
}),
});
if
(
!
response
.
ok
)
{
await
response
.
text
();
throw
new
Error
(
`Failed to create memo (
${
response
.
status
}
)`
);
}
const
result
=
await
response
.
json
();
return
result
;
}
/**
* Create a memo using a direct token
*/
export
async
function
createMemoWithToken
(
token
:
string
,
data
:
CreateMemoRequest
):
Promise
<
MemoResponse
>
{
const
apiBase
=
await
getApiBase
();
const
response
=
await
fetch
(
`
${
apiBase
}
/memos`
,
{
method
:
'POST'
,
headers
:
{
'Content-Type'
:
'application/json'
,
'Authorization'
:
`Bearer
${
token
}
`
,
},
body
:
JSON
.
stringify
({
content
:
data
.
content
,
tags
:
data
.
tags
||
[],
visibility
:
data
.
visibility
||
'PRIVATE'
,
}),
});
if
(
!
response
.
ok
)
{
const
errorText
=
await
response
.
text
();
throw
new
Error
(
`Failed to save (
${
response
.
status
}
):
${
errorText
.
substring
(
0
,
100
)}
`
);
}
return
await
response
.
json
();
}
// ========== Tags ==========
export
async
function
fetchTags
():
Promise
<
string
[]
>
{
try
{
const
apiBase
=
await
getApiBase
();
const
headers
=
await
apiHeaders
();
const
response
=
await
fetch
(
`
${
apiBase
}
/memos`
,
{
headers
});
if
(
!
response
.
ok
)
return
[];
const
memos
:
any
[]
=
await
response
.
json
();
const
tagSet
=
new
Set
<
string
>
();
for
(
const
memo
of
memos
)
{
if
(
Array
.
isArray
(
memo
.
tags
))
{
memo
.
tags
.
forEach
((
t
:
string
)
=>
tagSet
.
add
(
t
));
}
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
)));
}
}
return
Array
.
from
(
tagSet
).
sort
();
}
catch
{
return
[];
}
}
// ========== Workspaces ==========
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
();
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
>
{
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